ComponentsData display

Item

A row in a list with no columns: Inbox threads, Needs you, tasks, connected accounts, search results. A leading slot, a title, an optional second line, and a trailing slot.

Playground

Change the controls to try each prop. The code under the preview updates as you go and lists only the props you changed; copy it into your page.

States
import { Item } from '@/ui/item';<Item />

One line

36px rows for tasks, settings lists and picker results: a 16px leading slot, the title, and trailing meta in a 64px slot so it lines up. An overdue date takes tone="danger".

import { CircleIcon } from '@phosphor-icons/react';
import { Item, ItemContent, ItemGroup, ItemMedia, ItemMeta, ItemTitle } from '@/ui/item';

const tasks = [
  { title: 'Send CVs to Halcyon Bank', due: 'Today', overdue: true },
  { title: 'Call Priya about notice period', due: 'Fri', overdue: false },
  { title: 'Brief Tom on Orbital Freight', due: 'Mon', overdue: false },
];

export default function ItemOneLine() {
  return (
    <ItemGroup variant="outline" aria-label="Tasks" className="max-w-135">
      {tasks.map((task) => (
        <Item key={task.title} render={<button type="button" />}>
          <ItemMedia>
            <CircleIcon aria-hidden />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{task.title}</ItemTitle>
          </ItemContent>
          <ItemMeta tone={task.overdue ? 'danger' : 'default'}>{task.due}</ItemMeta>
        </Item>
      ))}
    </ItemGroup>
  );
}

Two lines

56px rows for Inbox and Needs you: a 32px avatar or tile, the title and one 12/16 line cut to one row.

import { Avatar } from '@/ui/avatar';
import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemMeta, ItemTitle } from '@/ui/item';

const threads = [
  { name: 'Sara Lund', company: 'Halcyon Bank', subject: 'Re: Senior Data Engineer shortlist', time: '09:42' },
  { name: 'James Reed', company: 'Orbital Freight', subject: 'Interview times for Thursday', time: 'Yesterday' },
  { name: 'Ava Nguyen', company: 'Northwind Logistics', subject: 'Offer accepted, start date 3 Nov', time: 'Mon' },
];

export default function ItemTwoLines() {
  return (
    <ItemGroup variant="outline" aria-label="Inbox" className="max-w-xl">
      {threads.map((thread) => (
        <Item key={thread.name} size="large" render={<a href={`#${thread.name.toLowerCase().replace(' ', '-')}`} />}>
          <ItemMedia>
            <Avatar name={thread.name} size="large" />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>
              {thread.name} · {thread.company}
            </ItemTitle>
            <ItemDescription>{thread.subject}</ItemDescription>
          </ItemContent>
          <ItemMeta>{thread.time}</ItemMeta>
        </Item>
      ))}
    </ItemGroup>
  );
}

Selected

selected marks the row whose detail is open, such as the thread shown beside the Inbox: the selected fill, a brand title and aria-current. It never marks a checked row.

import { BankIcon, BuildingsIcon, TruckIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Item, ItemContent, ItemGroup, ItemMedia, ItemTitle } from '@/ui/item';

const companies = [
  { name: 'Halcyon Bank', icon: BankIcon },
  { name: 'Orbital Freight', icon: TruckIcon },
  { name: 'Northwind Logistics', icon: BuildingsIcon },
];

// The selected row is the one whose detail is open. Click a row to open it.
export default function ItemSelected() {
  const [open, setOpen] = useState('Halcyon Bank');
  return (
    <ItemGroup aria-label="Companies" className="max-w-74">
      {companies.map(({ name, icon: Icon }) => (
        <Item key={name} selected={open === name} render={<button type="button" />} onClick={() => setOpen(name)}>
          <ItemMedia>
            <Icon aria-hidden />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{name}</ItemTitle>
          </ItemContent>
        </Item>
      ))}
    </ItemGroup>
  );
}

Static

A row that does nothing has no hover, press or focus. Only a row rendered as a link or button takes them.

Halcyon Bank
import { BankIcon } from '@phosphor-icons/react';
import { Item, ItemContent, ItemMedia, ItemTitle } from '@/ui/item';

// A row that does nothing has no hover, press or focus.
export default function ItemStatic() {
  return (
    <Item className="max-w-74">
      <ItemMedia>
        <BankIcon aria-hidden />
      </ItemMedia>
      <ItemContent>
        <ItemTitle>Halcyon Bank</ItemTitle>
      </ItemContent>
    </Item>
  );
}

Grouped, with actions

ItemGroup variant="outline" puts the rows in a raised card with a hairline between them. The trailing slot holds a status and one action, never two buttons.

  • Mailboxpriya@northbridge.co · synced 2 minutes agoConnected
  • CalendarMeetings with clients and candidates appear on records.
import { CalendarBlankIcon, DotsThreeIcon, TrayIcon } from '@phosphor-icons/react';
import { Button, IconButton } from '@/ui/button';
import { IconTile } from '@/ui/icon-tile';
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from '@/ui/item';
import { Lozenge } from '@/ui/lozenge';

export default function ItemGroupedWithActions() {
  return (
    <ItemGroup variant="outline" aria-label="Connected accounts" className="max-w-160">
      <Item size="large">
        <ItemMedia>
          <IconTile>
            <TrayIcon />
          </IconTile>
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Mailbox</ItemTitle>
          <ItemDescription>priya@northbridge.co · synced 2 minutes ago</ItemDescription>
        </ItemContent>
        <ItemActions>
          <Lozenge appearance="success">Connected</Lozenge>
          <IconButton label="Mailbox options">
            <DotsThreeIcon />
          </IconButton>
        </ItemActions>
      </Item>
      <Item size="large">
        <ItemMedia>
          <IconTile>
            <CalendarBlankIcon />
          </IconTile>
        </ItemMedia>
        <ItemContent>
          <ItemTitle>Calendar</ItemTitle>
          <ItemDescription>Meetings with clients and candidates appear on records.</ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button>Connect</Button>
        </ItemActions>
      </Item>
    </ItemGroup>
  );
}

Long and missing content

A long title or second line cuts with an ellipsis and shows in full on hover. Names in any script fit the same row. A missing value shows an em dash, never a blank.

import { Avatar } from '@/ui/avatar';
import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemMeta, ItemTitle } from '@/ui/item';

const people = [
  { name: 'Nguyễn Thị Minh Khai', detail: 'Head of Data, Halcyon Bank and Northwind Logistics joint venture in Ho Chi Minh City', time: '09:42' },
  { name: '王小明', detail: 'Shortlisted for Senior Data Engineer', time: 'Yesterday' },
  { name: "Zoë O'Brien-Smith", detail: null, time: null },
];

// Long text cuts with an ellipsis and shows in full on hover. A missing value shows an em dash, never a blank.
export default function ItemContentExtremes() {
  return (
    <ItemGroup variant="outline" aria-label="Candidates" className="max-w-sm">
      {people.map((person) => (
        <Item key={person.name} size="large" render={<a href={`#${encodeURIComponent(person.name)}`} />}>
          <ItemMedia>
            <Avatar name={person.name} size="large" />
          </ItemMedia>
          <ItemContent>
            <ItemTitle>{person.name}</ItemTitle>
            <ItemDescription>{person.detail ?? '—'}</ItemDescription>
          </ItemContent>
          <ItemMeta>{person.time ?? '—'}</ItemMeta>
        </Item>
      ))}
    </ItemGroup>
  );
}

Loading

While a list loads, skeleton rows of the same height hold its place. They appear only after 300ms, so a fast load never flashes, and screen readers hear "Loading Inbox".

import { Item, ItemContent, ItemGroup, ItemMedia } from '@/ui/item';
import { Skeleton, SkeletonGroup } from '@/ui/skeleton';

// While a list loads, rows of the same height hold its place, so nothing jumps when the data arrives.
export default function ItemLoading() {
  return (
    <SkeletonGroup label="Loading Inbox" className="w-full max-w-xl">
      <ItemGroup variant="outline" aria-hidden>
        {[160, 220, 180].map((width) => (
          <Item key={width} size="large">
            <ItemMedia>
              <Skeleton shape="avatar" width={32} height={32} />
            </ItemMedia>
            <ItemContent className="gap-100">
              <Skeleton width={width} />
              <Skeleton width={width + 80} height={12} />
            </ItemContent>
          </Item>
        ))}
      </ItemGroup>
    </SkeletonGroup>
  );
}

Empty

An empty list says why and what to do next. First use offers the action that fills it; no matches offers a way back.

No tasks yet

Add one to remind yourself to call or chase someone.

No tasks match “Halcyon”

Check the spelling or clear the search.

import { Button } from '@/ui/button';
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/ui/empty';

// An empty list says why it is empty and what to do next. No matches offers a way back.
export default function ItemEmpty() {
  return (
    <div className="grid w-full max-w-xl gap-300 sm:grid-cols-2">
      <Empty size="inline">
        <EmptyHeader>
          <EmptyTitle>No tasks yet</EmptyTitle>
          <EmptyDescription>Add one to remind yourself to call or chase someone.</EmptyDescription>
        </EmptyHeader>
        <Button>Add task</Button>
      </Empty>
      <Empty size="inline">
        <EmptyHeader>
          <EmptyTitle>No tasks match “Halcyon”</EmptyTitle>
          <EmptyDescription>Check the spelling or clear the search.</EmptyDescription>
        </EmptyHeader>
        <Button>Clear search</Button>
      </Empty>
    </div>
  );
}

Motion

Hover, press and selection change the fill and the title colour with the state change pattern. Press Replay to watch the selection move.

import { useState } from 'react';
import { Button } from '@/ui/button';
import { Item, ItemContent, ItemGroup, ItemTitle } from '@/ui/item';

const rows = ['Halcyon Bank', 'Orbital Freight', 'Northwind Logistics'];

// Replay moves the selection down the list: the fill and the title colour change in 160ms.
export default function ItemMotion() {
  const [open, setOpen] = useState('Halcyon Bank');
  const replay = () => rows.forEach((row, i) => setTimeout(() => setOpen(row), (i + 1) * 500));
  return (
    <div className="flex w-full max-w-74 flex-col items-start gap-300">
      <ItemGroup aria-label="Companies">
        {rows.map((row) => (
          <Item key={row} selected={open === row} render={<button type="button" />} onClick={() => setOpen(row)}>
            <ItemContent>
              <ItemTitle>{row}</ItemTitle>
            </ItemContent>
          </Item>
        ))}
      </ItemGroup>
      <Button size="sm" onClick={replay}>
        Replay
      </Button>
    </div>
  );
}

On this page