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.
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.
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.
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>
);
}Icon tile
A 16px icon on a 32px rounded tile. It is how Ascern shows an icon larger than 16px: in empty states, setup steps and rule templates.
Lozenge
A status: where a record stands right now. Stages, Job status, Do not contact, meeting status. Soft fill with dark text of the same hue, so it reads at a glance without shouting.