Skip to content

Item

A list row with media, title, description and actions.

Category
Layout
Adoption
Not used yet
import { Item } from "@oration/canon/components/item";
packages/canon/src/components/item.tsx

Suppliers missing a W-9

Northwind Freight

W-9 expired Aug 31. Two invoices on hold.

Halcyon Supply

No W-9 on file. First payment due Oct 2.

Orchard Street Bakery

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 Link through render.
  • 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

Dense rows are 13px with 12px meta. Item's title and description default to 14px, so rows in cards pass text-[13px] to the title and text-xs to the description.

The Tint Well Rule

Rows inside a card fill Well Gray on hover and sit flush; they don't become outlined or shadowed mini-cards. Save outline for a standalone row on the plane.

Anatomy#

Chase, ending 4417

Verified Sep 12 by Priya Raman

  1. 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.
  2. Media. ItemMedia: an icon, avatar, monogram tile or image. It aligns to the top of the row when a description is present.
  3. Title. ItemTitle: one line, medium weight, truncated with line-clamp-1.
  4. Description. ItemDescription: up to two lines of Slate Meta, clamped. Links inside are underlined.
  5. 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%.

Remittance email

Default: Transparent

Remittance email

Outline: Hairline border

Remittance email

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.

WZ
Wen Zhou

Default, support agent

WZ
Wen Zhou

Small, support agent

WZ
Wen Zhou

Extra 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>    );}

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>    );}
States
StateTreatment
RestTransparent (default), a hairline border (outline) or Well Gray at 50% (muted).
HoverOnly 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 visibleAn indigo border and a 3px Focus Indigo ring at 50%, when the item itself is focusable (a link or a button).
DisabledItem has no disabled state. Disable the action inside it and say why in the description.

Behavior#

  • render swaps the element while keeping the item's classes, so render={<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: ItemHeader and ItemFooter take 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 ItemContent after the first stops growing (flex-none), for a trailing value column such as an amount.
  • ItemGroup stacks items with 16px between them, 10px when it holds sm items and 8px for xs. Pass gap-0 for 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#

Halcyon Supply

14 open invoices

Do. Set dense rows at 13px with 12px meta, like every other list in a card.
Halcyon Supply

14 open invoices

Don't. Leave the 14px defaults in a dense list. The rows read larger than the table and nav around them.
Northwind Freight
Halcyon Supply
Do. Let rows inside a card sit flush and fill Well Gray on hover.
Northwind Freight
Halcyon Supply
Don't. Outline and shadow each row inside a card. It turns a list into a stack of nested cards.

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#

  • ItemGroup sets role="list", but Item sets no role. Pass role="listitem" to static items. For link rows, use a <ul> of <li>s instead of ItemGroup, 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-hidden and leave avatars without alt text.
Keyboard interactions
KeysAction
TabMoves to the row when it is a link, then to each action inside a static row.
EnterFollows a link row.

Design tokens#

Design tokens
TokenUsed for
--mutedHover fill of link rows; muted at 50%
--borderThe outline variant's stroke
--ringFocus border and 3px ring at 50%
--muted-foregroundDescription text
--radius-lg10px row corners
--radius-sm6px 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>.

Props of Item
PropTypeDefaultDescription
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.
renderReactElement | (props, state) => ReactElementNo defaultRender as another element, usually <Link href />. Anchors get the hover fill.
classNamestringNo defaultMerged 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>.

Props of ItemMedia
PropTypeDefaultDescription
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.

ItemFooter

A full-width line below 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.