Item
A row of media, title, description and actions, for lists of things.
shadcn's base-nova item with three changes: its hover is timed on --duration-fast rather than a fixed 100ms, so it stops with everything else under reduced motion; a row rendered as a link or a button wears the kit's focus-ring, the solid 2px accent outline, where shadcn's 3px halo at half strength measured 1.98:1 on white; and ItemSeparator is hidden from the accessibility tree, so a group's list owns its items alone. Media, title, description and actions sit in a wrapping row, in three treatments and three sizes. ItemGroup is a list, so give each Item role="listitem" inside one — or, for a row that is a link, wrap it in an element that is one, since the role on the anchor would take its link role away.
Install
npx shadcn@latest add @vibra/itemNeeds the @vibra registry in your components.json — set it up once.
Examples
import { BellRingIcon, KeyRoundIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
export default function ItemDemo() {
return (
<ItemGroup aria-label="Security" className="w-full max-w-md">
<Item role="listitem" variant="outline">
<ItemMedia variant="icon">
<KeyRoundIcon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>Two-step sign-in</ItemTitle>
<ItemDescription>Required for every admin.</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="outline" size="sm">
Manage
</Button>
</ItemActions>
</Item>
<Item role="listitem" variant="outline">
<ItemMedia variant="icon">
<BellRingIcon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>Sign-in alerts</ItemTitle>
<ItemDescription>An email for each new device.</ItemDescription>
</ItemContent>
<ItemActions>
<Button variant="outline" size="sm">
Turn on
</Button>
</ItemActions>
</Item>
</ItemGroup>
)
}With a face
People as rows, each face hidden beside the name it pictures. The invite button says whose invite it resends.
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
const MEMBERS = [
{ name: "Sonia Keller", initials: "SK", detail: "Owner · active today" },
{ name: "Kwame Halvorsen", initials: "KH", detail: "Admin · active 5 days ago" },
{ name: "Aisha Gallo", initials: "AG", detail: "Admin · invited yesterday", invited: true },
]
// The face is a picture of the name beside it, so it is hidden; the invite
// button says whose invite it resends, since every row could have one.
export default function ItemAvatar() {
return (
<ItemGroup aria-label="Owner and admins" className="w-full max-w-md">
{MEMBERS.map((member) => (
<Item key={member.name} role="listitem" size="sm">
<ItemMedia>
<Avatar aria-hidden="true">
<AvatarImage src={avatarFor(member.name)} alt="" />
<AvatarFallback>{member.initials}</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle>{member.name}</ItemTitle>
<ItemDescription className="truncate">{member.detail}</ItemDescription>
</ItemContent>
{member.invited ? (
<ItemActions>
<Button variant="outline" size="sm">
Resend <span className="sr-only">the invite to {member.name}</span>
</Button>
</ItemActions>
) : null}
</Item>
))}
</ItemGroup>
)
}With actions
The common action in the row and the rest in its menu, every button named for the file it acts on.
import {
DownloadIcon,
EllipsisIcon,
FileSpreadsheetIcon,
FileTextIcon,
LinkIcon,
PencilIcon,
Trash2Icon,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
const FILES = [
{ name: "hiring-plan.pptx", detail: "19.1 MB · Nadia Larsen · 27 Jul", icon: FileTextIcon },
{ name: "release-checklist.docx", detail: "10.9 MB · Aisha Nakamura · 2 May", icon: FileTextIcon },
{ name: "customer-interviews.xlsx", detail: "8.0 MB · Aisha Nakamura · 15 Apr", icon: FileSpreadsheetIcon },
]
// The common action sits in the row; the rest wait in its menu. Every button
// is named for the file it acts on, so a list of "Download, Download,
// Download" reads as three different files.
export default function ItemActionsExample() {
return (
<ItemGroup aria-label="Shared files" className="w-full max-w-md">
{FILES.map((file) => (
<Item key={file.name} role="listitem" variant="outline" size="sm">
<ItemMedia variant="icon">
<file.icon aria-hidden="true" />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="font-mono text-xs">{file.name}</ItemTitle>
<ItemDescription className="truncate text-xs">{file.detail}</ItemDescription>
</ItemContent>
<ItemActions className="gap-1">
<Button variant="ghost" size="icon-sm" aria-label={`Download ${file.name}`}>
<DownloadIcon aria-hidden="true" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${file.name}`} />}
>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem>
<PencilIcon aria-hidden="true" />
Rename
</DropdownMenuItem>
<DropdownMenuItem>
<LinkIcon aria-hidden="true" />
Copy link
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
Delete file
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ItemActions>
</Item>
))}
</ItemGroup>
)
}As a link
Whole rows as links, inside list items rather than being them. The external one says it opens a new tab, and the chevron turns for right to left.
import { BookOpenIcon, ChevronRightIcon, ExternalLinkIcon, ReceiptIcon } from "lucide-react"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
// The whole row is the link. It sits inside a list item rather than being
// one: role="listitem" on the anchor would take its link role away. The
// chevron turns round for a right-to-left page; the one leaving the product
// says so in its name.
export default function ItemLink() {
return (
<ItemGroup aria-label="Billing" className="w-full max-w-md gap-1">
<div role="listitem">
<Item variant="outline" render={<a href="/finance/invoices" />}>
<ItemMedia variant="icon">
<ReceiptIcon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>Invoices</ItemTitle>
<ItemDescription>63 unpaid, $219,087.30 in all.</ItemDescription>
</ItemContent>
<ItemActions>
<ChevronRightIcon aria-hidden="true" className="size-4 text-muted-foreground rtl:rotate-180" />
</ItemActions>
</Item>
</div>
<div role="listitem">
<Item variant="outline" render={<a href="https://docs.northwind.example/billing/proration" target="_blank" rel="noreferrer" />}>
<ItemMedia variant="icon">
<BookOpenIcon aria-hidden="true" />
</ItemMedia>
<ItemContent>
<ItemTitle>
How proration works <span className="sr-only">(opens in a new tab)</span>
</ItemTitle>
<ItemDescription>Billing guide · 4 min read</ItemDescription>
</ItemContent>
<ItemActions>
<ExternalLinkIcon aria-hidden="true" className="size-4 text-muted-foreground" />
</ItemActions>
</Item>
</div>
</ItemGroup>
)
}Settings row
A switch named by the row's title and described by its line, so it reads whole on its own.
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@/components/ui/item"
import { Switch } from "@/components/ui/switch"
const SETTINGS = [
{
id: "orders",
title: "Accept new orders",
description: "Turn it off to pause checkout while the store is closed.",
on: true,
},
{
id: "stock",
title: "Low-stock alerts",
description: "An email when a product falls below its reorder point. 13 are below it today.",
on: true,
},
{
id: "guests",
title: "Guest checkout",
description: "Shoppers can pay without making an account.",
on: false,
},
]
// The row's title names the switch and its line describes it, so a screen
// reader hears the setting and what it does from the switch alone.
export default function ItemSettings() {
return (
<ItemGroup aria-label="Store settings" className="w-full max-w-md gap-0 divide-y divide-border rounded-lg ring-1 ring-border">
{SETTINGS.map((setting) => (
<Item key={setting.id} role="listitem" className="rounded-none">
<ItemContent>
<ItemTitle id={`store-${setting.id}-title`}>{setting.title}</ItemTitle>
{/* Help text is never cut short, as the two-line clamp would. */}
<ItemDescription id={`store-${setting.id}-description`} className="line-clamp-none">
{setting.description}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
defaultChecked={setting.on}
aria-labelledby={`store-${setting.id}-title`}
aria-describedby={`store-${setting.id}-description`}
/>
</ItemActions>
</Item>
))}
</ItemGroup>
)
}Group with separators
Orders parted by ItemSeparator, drawn for the eye and hidden from the list, whose members are the orders alone.
import * as React from "react"
import {
Item,
ItemContent,
ItemDescription,
ItemGroup,
ItemSeparator,
ItemTitle,
} from "@/components/ui/item"
import { StatusBadge } from "@/components/ui/status-badge"
const ORDERS = [
{ number: "ORD-100037", customer: "Northwind Studio", detail: "3 items · 3 Sep", total: "$367.98", status: "fulfilled" },
{ number: "ORD-100086", customer: "Lumen Studio", detail: "3 items · 3 Sep", total: "$794.00", status: "cancelled" },
{ number: "ORD-100013", customer: "Foxglove Health", detail: "4 items · 30 Aug", total: "$1,285.00", status: "fulfilled" },
]
// The rules between the rows are ItemSeparator: drawn for the eye and hidden
// from the list, whose members are the orders alone.
export default function ItemGroupExample() {
return (
<ItemGroup aria-label="Latest orders" className="w-full max-w-md gap-0">
{ORDERS.map((order, index) => (
<React.Fragment key={order.number}>
{index > 0 ? <ItemSeparator className="my-0" /> : null}
<Item role="listitem" size="sm" className="px-1">
<ItemContent className="min-w-0">
<ItemTitle>{order.customer}</ItemTitle>
<ItemDescription>
<span className="font-mono text-xs">{order.number}</span> · {order.detail}
</ItemDescription>
</ItemContent>
<div className="flex flex-col items-end gap-1">
<span className="text-sm font-medium tabular-nums">{order.total}</span>
<StatusBadge status={order.status} />
</div>
</Item>
</React.Fragment>
))}
</ItemGroup>
)
}Selectable
Each row is its checkbox's label. A chosen row takes the selected plane with the tick, and a status line counts the choice.
"use client"
import * as React from "react"
import { avatarFor } from "@/lib/avatars"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldLegend, FieldSet } from "@/components/ui/field"
import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@/components/ui/item"
const PEOPLE = [
{ name: "Sonia Keller", initials: "SK", role: "Owner" },
{ name: "Farid Meyer", initials: "FM", role: "Admin" },
{ name: "Nadia Larsen", initials: "NL", role: "Member" },
]
// Each row is the checkbox's label, so a press anywhere on it ticks the box. A
// chosen row takes the selected plane as well as the tick, never colour alone.
export default function ItemSelectable() {
const [chosen, setChosen] = React.useState<string[]>(["Sonia Keller", "Farid Meyer"])
return (
<FieldSet className="w-full max-w-md gap-2">
<FieldLegend variant="label">Send the weekly revenue report to</FieldLegend>
<div className="flex flex-col gap-1">
{PEOPLE.map((person) => {
const id = `weekly-report-${person.initials.toLowerCase()}`
return (
<Item
key={person.name}
size="sm"
render={<label htmlFor={id} />}
className="cursor-pointer hover:bg-muted/50 has-[[data-checked]]:bg-brand-muted"
>
<ItemMedia>
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(person.name)} alt="" />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
</ItemMedia>
<ItemContent>
<ItemTitle>{person.name}</ItemTitle>
<ItemDescription className="text-xs">{person.role}</ItemDescription>
</ItemContent>
<ItemActions>
<Checkbox
id={id}
checked={chosen.includes(person.name)}
onCheckedChange={(checked) =>
setChosen((current) =>
checked ? [...current, person.name] : current.filter((name) => name !== person.name)
)
}
/>
</ItemActions>
</Item>
)
})}
</div>
<p role="status" className="text-xs text-muted-foreground">
{chosen.length === 0
? "Nobody gets it yet."
: `${chosen.length} of ${PEOPLE.length} get it on Mondays at 08:00.`}
</p>
</FieldSet>
)
}Dense and regular
The same rows at the regular size for a page and the dense one for a side panel, switched in place.
"use client"
import * as React from "react"
import { LandmarkIcon } from "lucide-react"
import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item"
import { SegmentedControl } from "@/components/ui/segmented-control"
const PAYOUTS = [
{ id: "sep-13", account: "Harbor Trust ··4402", date: "Sun 13 Sep", amount: "$2,228.28" },
{ id: "sep-15", account: "Meridian Bank ··0917", date: "Tue 15 Sep", amount: "€1,756.71" },
{ id: "sep-27", account: "Meridian Bank ··0917", date: "Sun 27 Sep", amount: "€486.72" },
]
const SIZES = [
{ value: "default", label: "Regular" },
{ value: "xs", label: "Dense" },
]
// Regular for a page, dense for a side panel or a popover: the rows tighten,
// the second line drops to the 12px register, and nothing is cut.
export default function ItemSizes() {
const [size, setSize] = React.useState("default")
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<SegmentedControl aria-label="Row size" size="sm" options={SIZES} value={size} onValueChange={setSize} />
<ItemGroup aria-label="Scheduled payouts" className="gap-0 rounded-lg ring-1 ring-border has-data-[size=xs]:gap-0">
{PAYOUTS.map((payout) => (
<Item key={payout.id} role="listitem" size={size as "default" | "xs"} className="rounded-none">
<ItemMedia variant="icon">
<LandmarkIcon aria-hidden="true" />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle>{payout.account}</ItemTitle>
<ItemDescription>Scheduled for {payout.date}</ItemDescription>
</ItemContent>
<span className="text-sm font-medium tabular-nums">{payout.amount}</span>
</Item>
))}
</ItemGroup>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "outline" | "muted" | "default" | Bare, bounded by a hairline, or on half the muted plane. |
| size | "default" | "sm" | "xs" | "default" | The row's padding and its media's size. |
| render | React.ReactElement | — | Renders the row as another element: a link or a button, which hovers on the muted plane and takes the focus outline, or the label of a checkbox inside it. |
| ItemMedia.variant | "default" | "icon" | "image" | "default" | Anything, a 16px icon, or an image cropped to a square. |
Dependencies
Source
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn(
"group/item-group flex w-full flex-col gap-4 has-data-[size=sm]:gap-2.5 has-data-[size=xs]:gap-2",
className
)}
{...props}
/>
)
}
// ItemGroup is a list, and a list owns list items alone: a separator among
// them is a member with the wrong role. The rule is for the eye — the items
// already part — so it is hidden from the accessibility tree.
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
aria-hidden="true"
className={cn("my-2", className)}
{...props}
/>
)
}
// A row rendered as a link or a button takes keyboard focus, and wears the
// kit's one focus treatment — a solid 2px outline in the accent, not shadcn's
// 3px halo at half strength, which measured 1.98:1 on white.
const itemVariants = cva(
"group/item flex w-full flex-wrap items-center rounded-lg border text-sm transition-colors duration-(--duration-fast) focus-ring [a]:transition-colors [a]:hover:bg-muted",
{
variants: {
variant: {
default: "border-transparent",
outline: "border-border",
muted: "border-transparent bg-muted/50",
},
size: {
default: "gap-2.5 px-3 py-2.5",
sm: "gap-2.5 px-3 py-2.5",
xs: "gap-2 px-2.5 py-2 in-data-[slot=dropdown-menu-content]:p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
render,
...props
}: useRender.ComponentProps<"div"> & VariantProps<typeof itemVariants>) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(itemVariants({ variant, size, className })),
},
props
),
render,
state: {
slot: "item",
variant,
size,
},
})
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-data-[slot=item-description]/item:translate-y-0.5 group-has-data-[slot=item-description]/item:self-start [&_svg]:pointer-events-none",
{
variants: {
variant: {
default: "bg-transparent",
icon: "[&_svg:not([class*='size-'])]:size-4",
image:
"size-10 overflow-hidden rounded-sm group-data-[size=sm]/item:size-8 group-data-[size=xs]/item:size-6 [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 group-data-[size=xs]/item:gap-0 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"line-clamp-1 flex w-fit items-center gap-2 text-sm leading-snug font-medium underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"line-clamp-2 text-start text-sm leading-normal font-normal text-muted-foreground group-data-[size=xs]/item:text-xs [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}