Product database
Postgres replica
Connect
import { DatabaseIcon, GlobeIcon, SmartphoneIcon } from "lucide-react" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item" ;
const rows = [
{ name: "Web SDK" , meta: "2.4M events/day" , icon: GlobeIcon, live: true },
{
name: "iOS SDK" ,
meta: "v3.2.0 · 3 apps" ,
icon: SmartphoneIcon,
live: true ,
},
{
name: "Product database" ,
meta: "Postgres replica" ,
icon: DatabaseIcon,
live: false ,
},
];
export default function ItemDemo () {
return (
< ItemGroup className = "w-full max-w-sm" >
{rows. map (( r ) => (
< Item key = {r.name} size = "sm" variant = {r.live ? "default" : "muted" }>
< ItemMedia variant = "icon" >
< r.icon />
</ ItemMedia >
< ItemContent >
< ItemTitle >{r.name}</ ItemTitle >
< ItemDescription >{r.meta}</ ItemDescription >
</ ItemContent >
< ItemActions >
{r.live ? (
< Badge size = "sm" variant = "dot" color = "success" >
Live
</ Badge >
) : (
< Button variant = "outline" size = "xs" >
Connect
</ Button >
)}
</ ItemActions >
</ Item >
))}
</ ItemGroup >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/item
The CLI installs dependencies and any other components this one uses.
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
itemVariants,
} from "@/components/ui/item" ;
< ItemGroup >
< Item >
< ItemMedia variant = "icon" >
< GlobeIcon />
</ ItemMedia >
< ItemContent >
< ItemTitle >Web SDK</ ItemTitle >
< ItemDescription >2.4M events/day</ ItemDescription >
</ ItemContent >
< ItemActions >
< Button variant = "outline" size = "xs" >Configure</ Button >
</ ItemActions >
</ Item >
</ ItemGroup >
Layout only, no collection behavior
Item is a styled <div> with no keyboard navigation or selection. It's
ideal for static rows, settings lists and rows with their own buttons. For
rows the user selects or navigates with arrow keys, use a
List Box or Grid List .
Item: a row that shows an entity (a file, an integration, a person, a payment method) with optional actions. Works in cards, sidebars, popovers and pages.
Grid List : an interactive list with selection, keyboard navigation or drag and drop.
List Box : a list of selectable options.
Table : when rows share several columns that users compare or sort.
Card : a larger, self-contained block with its own header and footer.
< ItemGroup > { /* vertical stack of rows */ }
< Item > { /* row: variant, size */ }
< ItemMedia /> { /* icon tile, round mark, image or avatar */ }
< ItemContent > { /* grows, truncates */ }
< ItemTitle /> { /* medium weight, can hold a badge */ }
< ItemDescription />{ /* quiet second line */ }
</ ItemContent >
< ItemActions /> { /* trailing buttons, switches, badges */ }
</ Item >
< ItemSeparator /> { /* inset hairline between rows */ }
</ ItemGroup >
Part Renders Notes ItemGroup<div>Flex column with a 2px gap. Carries data-slot="item-group". Item<div>Row. Flex, centered, 12px gap, rounded. Sets the group/item Tailwind group. ItemMedia<div>Leading visual. icon, round and image variants set the size and surface. ItemContent<div>Takes the remaining width (flex-1 min-w-0) so long text truncates instead of pushing actions out. ItemTitle<div>Single line, truncated. Flex with a gap, so an inline badge or icon sits next to the text. ItemDescription<p>Muted, single line, truncated. ItemActions<div>Trailing flex row that never shrinks. ItemSeparator<div>1px line inset 12px from each side. Decorative.
default is transparent, for rows inside a list, card or popover. muted fills the row to highlight it, for example the selected, unread or featured one. outline gives the row a bordered card surface so it can stand on its own.
default
Transparent. Rows inside a list or card.
muted
Muted fill. Selected or featured rows.
outline
Bordered card surface. Standalone rows.
import { ChevronRightIcon, FileTextIcon } from "lucide-react" ;
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item" ;
const variants = [
{ variant: "default" , note: "Transparent. Rows inside a list or card." },
{ variant: "muted" , note: "Muted fill. Selected or featured rows." },
{ variant: "outline" , note: "Bordered card surface. Standalone rows." },
] as const ;
export default function ItemVariants () {
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
{variants. map (( v ) => (
< Item key = {v.variant} variant = {v.variant}>
< ItemMedia variant = "icon" >
< FileTextIcon />
</ ItemMedia >
< ItemContent >
< ItemTitle className = "capitalize" >{v.variant}</ ItemTitle >
< ItemDescription >{v.note}</ ItemDescription >
</ ItemContent >
< ItemActions >
< ChevronRightIcon
className = "size-4 text-muted-foreground"
aria-hidden
/>
</ ItemActions >
</ Item >
))}
</ div >
);
}
size sets the row padding: sm for dense lists and sidebars, md (default) for most lists, lg for spacious settings pages.
Quarterly reports
size="sm" · 14 files
Quarterly reports
size="md" · 14 files
Quarterly reports
size="lg" · 14 files
import { FolderIcon } from "lucide-react" ;
import {
Item,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item" ;
export default function ItemSizes () {
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
{([ "sm" , "md" , "lg" ] as const ). map (( size ) => (
< Item key = {size} size = {size} variant = "outline" >
< ItemMedia variant = "icon" >
< FolderIcon />
</ ItemMedia >
< ItemContent >
< ItemTitle >Quarterly reports</ ItemTitle >
< ItemDescription >size="{size}" · 14 files</ ItemDescription >
</ ItemContent >
</ Item >
))}
</ div >
);
}
icon is a bordered 36px tile for icons and logo marks, round a 36px circle you color yourself, and image a 40px cover-fit thumbnail for an <img>. The default variant adds no size or surface, so any element fits, such as an Avatar , or your own tinted tile.
Icon tile
variant="icon" · bordered 36px square
Round mark
variant="round" · brand-colored circle
Image thumbnail
variant="image" · 40px, cover-fit
OM
Any element
variant="default" · e.g. an Avatar
Custom tint
Your own size and colors
import { HashIcon, PenToolIcon, ZapIcon } from "lucide-react" ;
import { Avatar } from "@/components/ui/avatar" ;
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item" ;
export default function ItemMediaExample () {
return (
< ItemGroup className = "w-full max-w-sm" >
< Item >
< ItemMedia variant = "icon" >
< PenToolIcon />
</ ItemMedia >
< ItemContent >
< ItemTitle >Icon tile</ ItemTitle >
< ItemDescription >
variant="icon" · bordered 36px square
</ ItemDescription >
</ ItemContent >
</ Item >
< Item >
< ItemMedia variant = "round" className = "bg-brand text-brand-foreground" >
< HashIcon />
</ ItemMedia >
< ItemContent >
< ItemTitle >Round mark</ ItemTitle >
< ItemDescription >
variant="round" · brand-colored circle
</ ItemDescription >
</ ItemContent >
</ Item >
< Item >
< ItemMedia variant = "image" >
{ /* biome-ignore lint/performance/noImgElement: framework-agnostic example */ }
< img src = "https://picsum.photos/seed/lisbon/80/80" alt = "" />
</ ItemMedia >
< ItemContent >
< ItemTitle >Image thumbnail</ ItemTitle >
< ItemDescription >variant="image" · 40px, cover-fit</ ItemDescription >
</ ItemContent >
</ Item >
< Item >
< ItemMedia >
< Avatar colorful alt = "" fallback = "OM" />
</ ItemMedia >
< ItemContent >
< ItemTitle >Any element</ ItemTitle >
< ItemDescription >variant="default" · e.g. an Avatar</ ItemDescription >
</ ItemContent >
</ Item >
< Item >
< ItemMedia className = "size-9 rounded-lg bg-warning/15 text-warning" >
< ZapIcon className = "size-4" />
</ ItemMedia >
< ItemContent >
< ItemTitle >Custom tint</ ItemTitle >
< ItemDescription >Your own size and colors</ ItemDescription >
</ ItemContent >
</ Item >
</ ItemGroup >
);
}
ItemActions holds buttons, a menu trigger or a Switch . ItemTitle lays out its children in a row, so a Badge sits next to the title. Label switches with the row's title via aria-labelledby, and give icon buttons an aria-label that names the row.
Visa ending in 4242Default
Expires 08/28
Edit
Two-factor authentication
Require a code at every sign-in.
Invoice INV-1042
Paid Sep 1, 2026 · $2,500.00
import {
CreditCardIcon,
DownloadIcon,
MoreHorizontalIcon,
ShieldCheckIcon,
} from "lucide-react" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item" ;
import { Switch } from "@/components/ui/switch" ;
export default function ItemActionsExample () {
return (
< div className = "flex w-full max-w-md flex-col gap-3" >
< Item variant = "outline" >
< ItemMedia variant = "icon" >
< CreditCardIcon />
</ ItemMedia >
< ItemContent >
< ItemTitle >
Visa ending in 4242
< Badge size = "sm" >Default</ Badge >
</ ItemTitle >
< ItemDescription >Expires 08/28</ ItemDescription >
</ ItemContent >
< ItemActions >
< Button variant = "outline" size = "xs" >
Edit
</ Button >
< Button
variant = "ghost"
size = "icon-xs"
aria-label = "More options for Visa ending in 4242"
>
< MoreHorizontalIcon />
</ Button >
</ ItemActions >
</ Item >
< Item variant = "outline" >
< ItemMedia variant = "icon" >
< ShieldCheckIcon />
</ ItemMedia >
< ItemContent >
< ItemTitle id = "two-factor-title" >Two-factor authentication</ ItemTitle >
< ItemDescription >Require a code at every sign-in.</ ItemDescription >
</ ItemContent >
< ItemActions >
< Switch aria-labelledby = "two-factor-title" defaultSelected />
</ ItemActions >
</ Item >
< Item variant = "outline" >
< ItemContent >
< ItemTitle >Invoice INV-1042</ ItemTitle >
< ItemDescription >Paid Sep 1, 2026 · $2,500.00</ ItemDescription >
</ ItemContent >
< ItemActions >
< Button variant = "ghost" size = "sm" >
< DownloadIcon /> PDF
</ Button >
</ ItemActions >
</ Item >
</ div >
);
}
For rows that navigate, apply itemVariants to an <a> (or a Next.js or React Aria Link). The styles add a hover background and a keyboard focus ring when the element is a link or button. Use the group-hover/item: variant to animate the chevron.
import {
BellIcon,
ChevronRightIcon,
KeyRoundIcon,
UserIcon,
} from "lucide-react" ;
import {
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
itemVariants,
} from "@/components/ui/item" ;
const links = [
{
href: "#profile" ,
icon: UserIcon,
title: "Profile" ,
description: "Name, photo and contact details" ,
},
{
href: "#notifications" ,
icon: BellIcon,
title: "Notifications" ,
description: "Email, push and in-app alerts" ,
},
{
href: "#security" ,
icon: KeyRoundIcon,
title: "Security" ,
description: "Password, passkeys and sessions" ,
},
];
export default function ItemAsLink () {
return (
< nav aria-label = "Account settings" className = "w-full max-w-sm" >
< ul className = "flex flex-col gap-0.5" >
{links. map (( l ) => (
< li key = {l.href}>
< a href = {l.href} className = { itemVariants ({ size: "sm" })}>
< ItemMedia variant = "icon" >
< l.icon />
</ ItemMedia >
< ItemContent >
< ItemTitle >{l.title}</ ItemTitle >
< ItemDescription >{l.description}</ ItemDescription >
</ ItemContent >
< ChevronRightIcon
aria-hidden
className = "size-4 text-muted-foreground transition-transform group-hover/item:translate-x-0.5"
/>
</ a >
</ li >
))}
</ ul >
</ nav >
);
}
ItemGroup stacks rows with a 2px gap. Put it in a bordered container with gap-0, and place ItemSeparators between rows for a divided list.
Q3 board update.pdf
2.4 MB · Edited 2h ago
Revenue model.xlsx
860 KB · Edited yesterday
Launch plan.key
18 MB · Edited Sep 24
import {
FileSpreadsheetIcon,
FileTextIcon,
PresentationIcon,
} from "lucide-react" ;
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item" ;
const files = [
{
name: "Q3 board update.pdf" ,
meta: "2.4 MB · Edited 2h ago" ,
icon: FileTextIcon,
},
{
name: "Revenue model.xlsx" ,
meta: "860 KB · Edited yesterday" ,
icon: FileSpreadsheetIcon,
},
{
name: "Launch plan.key" ,
meta: "18 MB · Edited Sep 24" ,
icon: PresentationIcon,
},
];
export default function ItemGroupExample () {
return (
< ItemGroup className = "w-full max-w-sm gap-0 rounded-xl border bg-card p-1" >
{files. map (( f , i ) => (
< div key = {f.name} className = "contents" >
{i > 0 && < ItemSeparator />}
< Item size = "sm" >
< ItemMedia variant = "icon" >
< f.icon />
</ ItemMedia >
< ItemContent >
< ItemTitle >{f.name}</ ItemTitle >
< ItemDescription >{f.meta}</ ItemDescription >
</ ItemContent >
</ Item >
</ div >
))}
</ ItemGroup >
);
}
A connected-apps list: icon tiles, a "Not connected" badge in the title, and either a switch or a connect button per row.
Team chat
Post deploy and incident alerts to #ops.
Calendar
Block focus time around on-call shifts.
Email digest
A weekly summary of usage and spend.
Data warehouseNot connected
Sync events to Snowflake or BigQuery.
Connect
import { CalendarIcon, DatabaseIcon, HashIcon, MailIcon } from "lucide-react" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item" ;
import { Switch } from "@/components/ui/switch" ;
const integrations = [
{
id: "chat" ,
name: "Team chat" ,
description: "Post deploy and incident alerts to #ops." ,
icon: HashIcon,
connected: true ,
enabled: true ,
},
{
id: "calendar" ,
name: "Calendar" ,
description: "Block focus time around on-call shifts." ,
icon: CalendarIcon,
connected: true ,
enabled: false ,
},
{
id: "email" ,
name: "Email digest" ,
description: "A weekly summary of usage and spend." ,
icon: MailIcon,
connected: true ,
enabled: true ,
},
{
id: "warehouse" ,
name: "Data warehouse" ,
description: "Sync events to Snowflake or BigQuery." ,
icon: DatabaseIcon,
connected: false ,
enabled: false ,
},
];
export default function ItemRecipeIntegrations () {
return (
< ItemGroup className = "w-full max-w-md gap-0 rounded-xl border bg-card p-1" >
{integrations. map (( i , index ) => (
< div key = {i.id} className = "contents" >
{index > 0 && < ItemSeparator />}
< Item >
< ItemMedia variant = "icon" >
< i.icon />
</ ItemMedia >
< ItemContent >
< ItemTitle id = { `${ i . id }-title` }>
{i.name}
{ ! i.connected && (
< Badge size = "sm" variant = "outline" >
Not connected
</ Badge >
)}
</ ItemTitle >
< ItemDescription >{i.description}</ ItemDescription >
</ ItemContent >
< ItemActions >
{i.connected ? (
< Switch
aria-labelledby = { `${ i . id }-title` }
defaultSelected = {i.enabled}
/>
) : (
< Button variant = "outline" size = "xs" >
Connect
</ Button >
)}
</ ItemActions >
</ Item >
</ div >
))}
</ ItemGroup >
);
}
Standalone outline rows with image thumbnails, a download button and a per-row Menu .
hero-lisbon.jpg
2.1 MB · 2400 × 1600
team-offsite.jpg
3.4 MB · 3000 × 2000
product-shot.png
860 KB · 1600 × 1200
import {
DownloadIcon,
MoreHorizontalIcon,
PencilIcon,
Trash2Icon,
} from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item" ;
import {
MenuContent,
MenuItem,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu" ;
const files = [
{ name: "hero-lisbon.jpg" , meta: "2.1 MB · 2400 × 1600" , seed: "lisbon" },
{ name: "team-offsite.jpg" , meta: "3.4 MB · 3000 × 2000" , seed: "offsite" },
{ name: "product-shot.png" , meta: "860 KB · 1600 × 1200" , seed: "product" },
];
export default function ItemRecipeFiles () {
return (
< ItemGroup className = "w-full max-w-md" >
{files. map (( f ) => (
< Item key = {f.name} variant = "outline" size = "sm" >
< ItemMedia variant = "image" >
{ /* biome-ignore lint/performance/noImgElement: framework-agnostic example */ }
< img src = { `https://picsum.photos/seed/${ f . seed }/80/80` } alt = "" />
</ ItemMedia >
< ItemContent >
< ItemTitle >{f.name}</ ItemTitle >
< ItemDescription >{f.meta}</ ItemDescription >
</ ItemContent >
< ItemActions >
< Button
variant = "ghost"
size = "icon-xs"
aria-label = { `Download ${ f . name }` }
>
< DownloadIcon />
</ Button >
< MenuTrigger >
< Button
variant = "ghost"
size = "icon-xs"
aria-label = { `More actions for ${ f . name }` }
>
< MoreHorizontalIcon />
</ Button >
< MenuContent placement = "bottom end" >
< MenuItem textValue = "Rename" >
< PencilIcon /> Rename
</ MenuItem >
< MenuSeparator />
< MenuItem textValue = "Delete" variant = "destructive" >
< Trash2Icon /> Delete
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
</ ItemActions >
</ Item >
))}
</ ItemGroup >
);
}
Unread rows use variant="muted" plus visually hidden "Unread" text, so the state isn't conveyed by color alone. Marking a row read switches it back to default.
IN
Isabella Nguyen requested your review on Billing v2
Unread. 5m ago
WK
William Kim mentioned you in Incident #214
Unread. 1h ago
SD
Sofia Davis shared Q4 roadmap with the team
Yesterday
import { CheckIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Avatar } from "@/components/ui/avatar" ;
import { Button } from "@/components/ui/button" ;
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item" ;
const initial = [
{
id: 1 ,
who: "Isabella Nguyen" ,
initials: "IN" ,
what: "requested your review on Billing v2" ,
time: "5m ago" ,
unread: true ,
},
{
id: 2 ,
who: "William Kim" ,
initials: "WK" ,
what: "mentioned you in Incident #214" ,
time: "1h ago" ,
unread: true ,
},
{
id: 3 ,
who: "Sofia Davis" ,
initials: "SD" ,
what: "shared Q4 roadmap with the team" ,
time: "Yesterday" ,
unread: false ,
},
];
export default function ItemRecipeNotifications () {
const [ items , setItems ] = useState (initial);
const markRead = ( id : number ) =>
setItems (( list ) =>
list. map (( n ) => (n.id === id ? { ... n, unread: false } : n)),
);
return (
< ItemGroup className = "w-full max-w-md" >
{items. map (( n ) => (
< Item key = {n.id} variant = {n.unread ? "muted" : "default" }>
< ItemMedia >
< Avatar colorful alt = "" fallback = {n.initials} />
</ ItemMedia >
< ItemContent >
< ItemTitle className = "block" >
{n.who}{ " " }
< span className = "font-normal text-muted-foreground" >
{n.what}
</ span >
</ ItemTitle >
< ItemDescription >
{n.unread && < span className = "sr-only" >Unread. </ span >}
{n.time}
</ ItemDescription >
</ ItemContent >
< ItemActions >
{n.unread && (
< Button
variant = "ghost"
size = "icon-xs"
aria-label = { `Mark notification from ${ n . who } as read` }
onPress = {() => markRead (n.id)}
>
< CheckIcon />
</ Button >
)}
</ ItemActions >
</ Item >
))}
</ ItemGroup >
);
}
Item and its parts are plain elements with no role. Wrap a list of rows in <ul>/<li>, or add role="list" / role="listitem", when the list structure matters to screen reader users.
ItemTitle is a <div>. If rows are sections with their own headings, put a heading element inside it.
Don't attach onClick to an Item <div>. For navigation, render the row as a link with itemVariants; for selection, use a Grid List .
Don't nest interactive elements inside a link row. If a row both navigates and has actions, use a stretched link in the title instead (see the interactive cards pattern).
Title and description truncate with an ellipsis. Truncated text is still read in full by screen readers; add a title attribute or a tooltip if sighted users need the full text too.
Give controls in ItemActions names that identify the row, e.g. aria-label="Download report.pdf" rather than just "Download".
Attribute On Present when data-slot="item-group"ItemGroupAlways data-slot="item"ItemAlways data-slot="item-media"ItemMediaAlways data-slot="item-content"ItemContentAlways data-slot="item-title"ItemTitleAlways data-slot="item-description"ItemDescriptionAlways data-slot="item-actions"ItemActionsAlways data-slot="item-separator"ItemSeparatorAlways
Item sets the group/item group, so children can react to row hover with group-hover/item:…. When itemVariants is applied to an <a> or <button>, it adds hover:bg-muted/70, a pointer (links) or default (buttons) cursor, left-aligned text for buttons and focus-visible ring styles.
The tailwind-variants function behind Item. Use it to turn links, buttons or React Aria collection items into rows.
import { itemVariants } from "@/components/ui/item" ;
< a href = "/settings/profile" className = { itemVariants ({ size: "sm" })}>…</ a >;
Also accepts every prop of <div>.
Also accepts every prop of <div>.
No extra props. Each accepts every prop of <div>.
No extra props. Accepts every prop of <p>.
Grid List : interactive rows with selection and keyboard navigation.
Card : a container for a group of items.
Avatar : media for rows about people.
Table : multi-column data.