Item
A list row with media, title, description and actions.
- Status
- Experimental
- Level
- Molecule
- Category
- Layout
- Adoption
- Not used yet
import { Item } from "@oration/canon/components/item";packages/canon/src/components/item.tsxSuppliers missing a W-9
W-9 expired Aug 31. Two invoices on hold.
No W-9 on file. First payment due Oct 2.
Name on the W-9 doesn't match the bank account.
import { Button } from "@oration/canon/components/button";import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle,} from "@oration/canon/components/item";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() { const headingId = React.useId(); const [requested, setRequested] = React.useState<string[]>([]); const suppliers = [ { id: "northwind", name: "Northwind Freight", color: "teal" as const, detail: "W-9 expired Aug 31. Two invoices on hold.", }, { id: "halcyon", name: "Halcyon Supply", color: "violet" as const, detail: "No W-9 on file. First payment due Oct 2.", }, { id: "orchard", name: "Orchard Street Bakery", color: "amber" as const, detail: "Name on the W-9 doesn't match the bank account.", }, ]; return ( <section aria-labelledby={headingId} className="w-full max-w-lg rounded-xl bg-card p-2 shadow-border" > <h2 id={headingId} className="px-2 pt-2 pb-1 text-sm font-semibold text-foreground" > Suppliers missing a W-9 </h2> <ItemGroup className="gap-0"> {suppliers.map((supplier) => { const sent = requested.includes(supplier.id); return ( <Item key={supplier.id} role="listitem" size="sm"> <ItemMedia> <MonogramTile name={supplier.name} color={supplier.color} /> </ItemMedia> <ItemContent className="min-w-0"> <ItemTitle className="text-[13px]"> {supplier.name} </ItemTitle> <ItemDescription className="line-clamp-1 text-xs"> {supplier.detail} </ItemDescription> </ItemContent> <ItemActions> <Button type="button" variant="outline" size="sm" disabled={sent} onClick={() => { setRequested((ids) => [ ...ids, supplier.id, ]); toast.add({ type: "success", title: "W-9 requested", description: `${supplier.name} gets a secure upload link by email.`, }); }} > {sent ? "Requested" : "Request W-9"} </Button> </ItemActions> </Item> ); })} </ItemGroup> </section> );}Usage#
Item is a list row: optional media, a title and description, and actions at the right, in one flexible container you can render as a link. Use it for short lists of records inside cards and panels, such as suppliers missing a W-9 or connected channels. It is experimental and not used by the product yet, which hand-rolls the same row, so the defaults still read a little large: pass 13px titles and 12px descriptions for dense rows. Don't reach for it in a data table; grid rows are the data grid's job.
When to use
- For a short list of records inside a card or panel: suppliers, channels, recent tickets.
- For a navigation row that opens a record or page, rendered as a
Linkthroughrender. - For a settings-style row with media, a label, a description and a switch or button at the right.
- For a single summary row with a header line and a footer line, such as a ticket preview.
When not to use
- For rows of records with columns people sort, filter and compare. Use Data grid
- For a labelled setting in a settings page, with its own layout and description rules. Use Settings section
- For a list that people reorder by dragging. Use Sortable list
- For items in a menu. Use Dropdown menu
- For a larger, standalone block that groups one topic. Use Card
The Thirteen-Fourteen Rule
text-[13px] to the title and text-xs to the description.The Tint Well Rule
outline for a standalone row on the plane.Anatomy#
Verified Sep 12 by Priya Raman
- Container.
Item: a wrapping flex row with 10px corners and a transparent 1px border that becomes the focus edge. 12px by 10px padding by default. - Media.
ItemMedia: an icon, avatar, monogram tile or image. It aligns to the top of the row when a description is present. - Title.
ItemTitle: one line, medium weight, truncated withline-clamp-1. - Description.
ItemDescription: up to two lines of Slate Meta, clamped. Links inside are underlined. - Actions.
ItemActions: buttons, switches or a chevron at the right edge, 8px apart.
Examples#
Variants
Default is transparent, for rows inside a card. Outline adds a hairline for a standalone row on the plane. Muted sits on Well Gray at 50%.
Default: Transparent
Outline: Hairline border
Muted: Well Gray at 50%
import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle,} from "@oration/canon/components/item";import { MailIcon } from "lucide-react";export function Variants() { const variants = [ { variant: "default" as const, label: "Default", note: "Transparent" }, { variant: "outline" as const, label: "Outline", note: "Hairline border", }, { variant: "muted" as const, label: "Muted", note: "Well Gray at 50%" }, ]; return ( <ItemGroup className="max-w-md"> {variants.map((entry) => ( <Item key={entry.variant} role="listitem" variant={entry.variant} > <ItemMedia variant="icon"> <MailIcon aria-hidden="true" className="text-muted-foreground" /> </ItemMedia> <ItemContent> <ItemTitle className="text-[13px]"> Remittance email </ItemTitle> <ItemDescription className="text-xs"> {entry.label}: {entry.note} </ItemDescription> </ItemContent> </Item> ))} </ItemGroup> );}Sizes
Default and sm share 12px by 10px padding today; xs tightens to 10px by 8px and drops the description to 12px. Shown here without overrides.
Default, support agent
Small, support agent
import { Avatar, AvatarFallback } from "@oration/canon/components/avatar";import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from "@oration/canon/components/item";export function Sizes() { const sizes = [ { size: "default" as const, label: "Default", avatar: "lg" as const }, { size: "sm" as const, label: "Small", avatar: "default" as const }, { size: "xs" as const, label: "Extra small", avatar: "sm" as const }, ]; return ( <div className="flex w-full max-w-md flex-col gap-3"> {sizes.map((entry) => ( <Item key={entry.size} variant="outline" size={entry.size}> <ItemMedia> <Avatar size={entry.avatar}> <AvatarFallback>WZ</AvatarFallback> </Avatar> </ItemMedia> <ItemContent> <ItemTitle>Wen Zhou</ItemTitle> <ItemDescription> {entry.label}, support agent </ItemDescription> </ItemContent> </Item> ))} </div> );}As a link
render={<Link href />} makes the whole row a link. Anchors get the Well Gray hover and the focus ring; a chevron says the row goes somewhere. Link rows sit in a <ul>, not ItemGroup, so each keeps its link role.
import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from "@oration/canon/components/item";import { ChevronRightIcon } from "lucide-react";import Link from "next/link";export function AsLink() { const pages = [ { href: "/design/components/card", title: "Card", description: "The raised container these rows sit in.", }, { href: "/design/components/well", title: "Well", description: "A tint well for sub-regions inside a card.", }, ]; return ( <ul className="flex w-full max-w-md flex-col gap-1"> {pages.map((page) => ( <li key={page.href}> <Item size="sm" render={<Link href={page.href} />}> <ItemContent> <ItemTitle className="text-[13px]"> {page.title} </ItemTitle> <ItemDescription className="text-xs"> {page.description} </ItemDescription> </ItemContent> <ItemActions> <ChevronRightIcon aria-hidden="true" className="size-4 text-muted-foreground" /> </ItemActions> </Item> </li> ))} </ul> );}In a card with switches
Flush rows split by ItemSeparator inside one card. Each title is a label for its switch, and providers show as neutral monogram tiles.
The supplier line and outbound W-9 calls
Speech to text for calls and voicemail
payments@cedarline.io, for remittance replies
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemSeparator, ItemTitle,} from "@oration/canon/components/item";import { MonogramTile } from "@oration/canon/components/monogram-tile";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function SettingsList() { const baseId = React.useId(); const [enabled, setEnabled] = React.useState<Record<string, boolean>>({ twilio: true, deepgram: true, smtp: false, }); const channels = [ { id: "twilio", name: "Twilio", description: "The supplier line and outbound W-9 calls", }, { id: "deepgram", name: "Deepgram", description: "Speech to text for calls and voicemail", }, { id: "smtp", name: "Payments mailbox", description: "payments@cedarline.io, for remittance replies", }, ]; return ( <div className="w-full max-w-lg rounded-xl bg-card p-2 shadow-border"> <ItemGroup className="gap-0"> {channels.map((channel, index) => ( <React.Fragment key={channel.id}> {index > 0 ? <ItemSeparator className="my-0" /> : null} <Item role="listitem" size="sm"> <ItemMedia> <MonogramTile name={channel.name} color="gray" size="lg" /> </ItemMedia> <ItemContent> <ItemTitle className="text-[13px]"> <label htmlFor={`${baseId}-${channel.id}`}> {channel.name} </label> </ItemTitle> <ItemDescription className="text-xs"> {channel.description} </ItemDescription> </ItemContent> <ItemActions> <Switch id={`${baseId}-${channel.id}`} checked={enabled[channel.id] ?? false} onCheckedChange={(checked) => { setEnabled((current) => ({ ...current, [channel.id]: checked, })); toast.add({ title: checked ? `${channel.name} on` : `${channel.name} off`, }); }} /> </ItemActions> </Item> </React.Fragment> ))} </ItemGroup> </div> );}States#
import { Item, ItemContent, ItemDescription, ItemTitle } from "@oration/canon/components/item";import { cn } from "@oration/canon/lib/utils";export function States() { const states = [ { label: "Rest", className: "" }, { label: "Hover", className: "bg-muted" }, { label: "Focus visible", className: "border-ring ring-[3px] ring-ring/50", }, ]; return ( <div className="grid w-full gap-4 sm:grid-cols-3"> {states.map((state) => ( <div key={state.label} className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> {state.label} </span> <Item size="sm" tabIndex={-1} className={cn("pointer-events-none", state.className)} render={<a href="#item-states" />} > <ItemContent> <ItemTitle className="text-[13px]"> Halcyon Supply </ItemTitle> <ItemDescription className="text-xs"> 14 open invoices </ItemDescription> </ItemContent> </Item> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | Transparent (default), a hairline border (outline) or Well Gray at 50% (muted). |
| Hover | Only when rendered as an anchor: the row fills Well Gray over 100ms. Rows rendered as buttons or divs don't react on their own. |
| Focus visible | An indigo border and a 3px Focus Indigo ring at 50%, when the item itself is focusable (a link or a button). |
| Disabled | Item has no disabled state. Disable the action inside it and say why in the description. |
Behavior#
renderswaps the element while keeping the item's classes, sorender={<Link href />}gives a real link with the hover fill and focus ring. Base UI merges your props with the item's.- The row wraps:
ItemHeaderandItemFootertake a full row (basis-full) above and below the media and content. - Media shifts down 2px and aligns to the top when the row has a description, so an icon sits beside the title line.
variant="image"sizes the box to 40, 32 or 24px by item size. - A second
ItemContentafter the first stops growing (flex-none), for a trailing value column such as an amount. ItemGroupstacks items with 16px between them, 10px when it holdssmitems and 8px forxs. Passgap-0for rows that sit flush inside a card.- At
size="xs"inside a dropdown menu (data-slot="dropdown-menu-content"), the item drops its own padding and lets the menu item pad it.
Do and don't#
14 open invoices
14 open invoices
Content#
- Titles are the record's name as people know it: Northwind Freight, Chase, ending 4417.
- Descriptions add one fact that explains why the row is here: W-9 expired Aug 31. Two invoices on hold. Keep it to one line in dense lists.
- Action labels start with a verb and fit the row: Request W-9, Open ticket. When the row itself opens something, use a chevron and no label.
- Don't join metadata with dots. Use a comma, a second line or the footer.
Accessibility#
ItemGroupsetsrole="list", butItemsets no role. Passrole="listitem"to static items. For link rows, use a<ul>of<li>s instead ofItemGroup, since the role would replace the link's own.- A navigation row should be a real link:
render={<Link href />}. Its accessible name is the whole row's text, so keep descriptions short. - Don't make the row a link and also put buttons inside it; nested interactive elements break. Use a link row with a chevron, or a static row with actions.
- Give switches and inputs in actions a label. In a settings row, a
<label htmlFor>around the title names the switch. - Media is decorative when the title names the record: mark icons
aria-hiddenand leave avatars without alt text.
| Keys | Action |
|---|---|
| Tab | Moves to the row when it is a link, then to each action inside a static row. |
| Enter | Follows a link row. |
Design tokens#
| Token | Used for |
|---|---|
--muted | Hover fill of link rows; muted at 50% |
--border | The outline variant's stroke |
--ring | Focus border and 3px ring at 50% |
--muted-foreground | Description text |
--radius-lg | 10px row corners |
--radius-sm | 6px corners on image media |
API reference#
Item
The row. Renders a <div> with data-slot="item".
Other props spread onto Base UI useRender props for <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "outline" | "muted" | "default" | Transparent, a hairline border, or Well Gray at 50%. |
size | "default" | "sm" | "xs" | "default" | Padding and gap. default and sm are both 12px by 10px today; xs is 10px by 8px with 12px descriptions. |
render | ReactElement | (props, state) => ReactElement | No default | Render as another element, usually <Link href />. Anchors get the hover fill. |
className | string | No default | Merged into the variant classes. Use text-[13px], not text-13, to change the size. |
ItemGroup
A role="list" column with gaps that follow the item size.
Other props spread onto <div>.
No props of its own.
ItemMedia
The leading media box.
Other props spread onto <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "icon" | "image" | "default" | icon sizes an unsized svg to 16px. image makes a 40, 32 or 24px box with 6px corners that crops an <img>. |
ItemContent
The flexible text column. Gap 4px, none at xs.
Other props spread onto <div>.
No props of its own.
ItemTitle
One line, 14px medium, clamped.
Other props spread onto <div>.
No props of its own.
ItemDescription
Up to two lines of 14px Slate Meta (12px at xs).
Other props spread onto <p>.
No props of its own.
ItemActions
The trailing actions, 8px apart.
Other props spread onto <div>.
No props of its own.
ItemHeader
A full-width line above the media and content.
Other props spread onto <div>.
No props of its own.
ItemSeparator
A horizontal hairline with 8px above and below.
Other props spread onto Separator.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Item isn't imported anywhere in apps/web. List rows in cards are hand-rolled, for example flex items-center gap-2.5 rounded-lg px-2 py-2 hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/40 on a Link in Home, customers and agent health.
Title and description default to 14px. Dense rows in DESIGN.md are 13px with 12px meta.
size="default" and size="sm" produce identical classes (gap-2.5 px-3 py-2.5).
The hover transition is 100ms; DESIGN.md sets hovers at 150ms. The focus ring is 50%, where the hand-rolled rows use 40%.
Hover only applies when the item is an anchor. A row rendered as a <button> gets no hover fill.
ItemGroup is role="list" but Item never sets role="listitem", so a group of items is an invalid list unless each item passes the role.
ItemMedia variant="icon" only sizes the svg. The Well Gray 32px icon tile DESIGN.md describes has to be added by hand.