Record peek
A record opened beside the list it came from: an application, a target pursuit, a meeting. The list stays live; the address carries the record, so the peek can be shared and Back closes it.
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 { RecordPeek } from '@/ui/record-peek';<RecordPeek title="Maya Chen" />Beside the list
Click a row. The peek opens 512px from the right edge at full height, and nothing dims behind it (Decided 2). The row stays selected, and clicking another row swaps the peek in place.
import { DotsThreeIcon } from '@phosphor-icons/react';
import { useState } from 'react';
import { Avatar } from '@/ui/avatar';
import { IconButton } from '@/ui/button';
import { Item, ItemContent, ItemGroup, ItemMedia, ItemMeta, ItemTitle } from '@/ui/item';
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { RecordPeek, RecordPeekProperty } from '@/ui/record-peek';
const pipeline: { name: string; stage: string; phase: 'working' | 'client' | 'offer'; owner: string }[] = [
{ name: 'Priya Shah', stage: 'Offer', phase: 'offer', owner: 'Joseph A.' },
{ name: 'Maya Chen', stage: 'Interview', phase: 'working', owner: 'Joseph A.' },
{ name: 'Tom Okafor', stage: 'CV sent', phase: 'client', owner: 'Sara Lund' },
];
// Click a row: the peek opens beside the list, nothing dims, and the row stays selected. Another row swaps it in place.
export default function RecordPeekBesideTheList() {
const [name, setName] = useState(pipeline[1].name);
const [isOpen, setIsOpen] = useState(false);
const row = pipeline.find((r) => r.name === name) ?? pipeline[1];
return (
<>
<ItemGroup variant="outline" aria-label="Senior Data Engineer pipeline" className="max-w-60">
{pipeline.map((r) => (
<Item
key={r.name}
selected={isOpen && name === r.name}
render={<button type="button" />}
onClick={() => {
setName(r.name);
setIsOpen(true);
}}
>
<ItemMedia>
<Avatar name={r.name} />
</ItemMedia>
<ItemContent>
<ItemTitle>{r.name}</ItemTitle>
</ItemContent>
<ItemMeta>{r.stage}</ItemMeta>
</Item>
))}
</ItemGroup>
<RecordPeek
open={isOpen}
onOpenChange={setIsOpen}
title={row.name}
description="Application · Senior Data Engineer"
fullPageHref="?record=application"
identity={
<>
<RecordChip type="candidate" name={row.name} />
<RecordChip type="job" name="Senior Data Engineer" domain="halcyonbank.com" />
<RecordChip type="company" name="Halcyon Bank" domain="halcyonbank.com" />
<Lozenge phase={row.phase}>{row.stage}</Lozenge>
</>
}
moreMenu={
<IconButton label="More actions" variant="secondary">
<DotsThreeIcon />
</IconButton>
}
>
<div className="flex flex-col gap-050">
<RecordPeekProperty label="Owner">
<Avatar name={row.owner} />
{row.owner}
</RecordPeekProperty>
<RecordPeekProperty label="State">Open</RecordPeekProperty>
<RecordPeekProperty label="Notice period">
<span className="text-text-subtlest">Add notice period</span>
</RecordPeekProperty>
</div>
</RecordPeek>
</>
);
}Review queue
In a queue, the line under the name counts the queue, and J and K step to the next and previous person. The one primary action leads the action row.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { RecordPeek, RecordPeekProperty } from '@/ui/record-peek';
const queue = ['Tom Okafor', 'Maya Chen', 'Priya Shah', 'Elena Watts', 'Sara Lund', 'James Reed'];
// In a review queue, J and K step to the next and previous person; the line under the name counts the queue.
export default function RecordPeekReviewQueue() {
const [open, setOpen] = useState(false);
const [index, setIndex] = useState(2);
return (
<>
<Button onClick={() => setOpen(true)}>Review Senior Data Engineer</Button>
<RecordPeek
open={open}
onOpenChange={setOpen}
title={queue[index]}
description="Senior Data Engineer"
queue={{
position: index + 1,
total: queue.length,
onPrevious: index > 0 ? () => setIndex(index - 1) : undefined,
onNext: index < queue.length - 1 ? () => setIndex(index + 1) : undefined,
}}
identity={
<>
<RecordChip type="candidate" name={queue[index]} />
<Lozenge phase="working">Short list</Lozenge>
</>
}
primaryAction={<Button variant="primary">Move to Outreach</Button>}
fullPageHref="?record=application"
>
<RecordPeekProperty label="Owner">Sara Lund</RecordPeekProperty>
</RecordPeek>
</>
);
}When the record changes or can't load
Pick a state, then open the peek. An archived record is read only until restored. A record deleted, merged or edited by someone else while open says who did what and what to do next. A linked record you can't see says so. Loading shows skeleton rows at their final height; a failed load offers Try again.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { Lozenge } from '@/ui/lozenge';
import { RecordChip } from '@/ui/record-chip';
import { RecordPeek, RecordPeekProperty } from '@/ui/record-peek';
import { SectionMessage } from '@/ui/section-message';
type State = 'archived' | 'deleted' | 'merged' | 'changed' | 'no access' | 'loading' | 'error';
const notices: Record<State, React.ReactNode> = {
archived: <SectionMessage appearance="information" title="Archived. Read only until restored." actions={<Button size="sm">Restore</Button>} />,
deleted: (
<SectionMessage appearance="warning" title="Sara Lund deleted this application a moment ago.">
Nothing here can be changed. Close the panel to go back to the pipeline.
</SectionMessage>
),
merged: (
<SectionMessage appearance="information" title="Elena Watts was merged into Elena Watts-Okoro." actions={<Button size="sm">Open Elena Watts-Okoro</Button>}>
Her applications, notes and files moved there.
</SectionMessage>
),
changed: (
<SectionMessage appearance="warning" title="James Reed changed the stage to Interview while you were here." actions={<Button size="sm">Show the latest</Button>}>
Your unsaved notice period is kept.
</SectionMessage>
),
'no access': null,
loading: null,
error: (
<SectionMessage appearance="error" title="Couldn't load this application." actions={<Button size="sm">Try again</Button>}>
Check your connection and try again.
</SectionMessage>
),
};
// Each state a peek can meet while it is open. Pick one, then open the peek.
export default function RecordPeekStates() {
const [state, setState] = useState<State>('archived');
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col items-start gap-200">
<div role="group" aria-label="State" className="flex flex-wrap gap-050">
{(Object.keys(notices) as State[]).map((s) => (
<Button key={s} size="sm" selected={state === s} onClick={() => setState(s)}>
{s[0].toUpperCase() + s.slice(1)}
</Button>
))}
</div>
<Button onClick={() => setOpen(true)}>Open Elena Watts</Button>
<RecordPeek
open={open}
onOpenChange={setOpen}
title="Elena Watts"
description="Application · Senior Data Engineer"
loading={state === 'loading'}
notice={notices[state]}
fullPageHref={state === 'deleted' ? undefined : '?record=application'}
identity={
state === 'error' || state === 'loading' ? undefined : (
<>
<RecordChip type="candidate" name="Elena Watts" />
{state !== 'no access' && <RecordChip type="company" name="Halcyon Bank" domain="halcyonbank.com" />}
<Lozenge phase="working">Short list</Lozenge>
{state === 'archived' && <Lozenge appearance="neutral">Archived</Lozenge>}
</>
)
}
>
{state !== 'error' && (
<div className="flex flex-col gap-050">
<RecordPeekProperty label="Owner">Sara Lund</RecordPeekProperty>
{state === 'no access' && (
<RecordPeekProperty label="Client">
<span className="font-normal text-text-subtle">You don't have access to this company. Ask an Admin to share it.</span>
</RecordPeekProperty>
)}
<RecordPeekProperty label="Notice period">
<span className="text-text-subtle">—</span>
</RecordPeekProperty>
</div>
)}
</RecordPeek>
</div>
);
}Motion
The peek slides in with the sheet pattern. Press Replay to watch it open and close.
import { useState } from 'react';
import { Button } from '@/ui/button';
import { RecordPeek, RecordPeekProperty } from '@/ui/record-peek';
// Replay opens the peek, then closes it: in 250ms on the drawer curve, out in 200ms.
export default function RecordPeekMotion() {
const [open, setOpen] = useState(false);
const replay = () => {
setOpen(true);
setTimeout(() => setOpen(false), 1200);
};
return (
<>
<Button size="sm" onClick={replay}>
Replay
</Button>
<RecordPeek open={open} onOpenChange={setOpen} title="Maya Chen" description="Application · Senior Data Engineer">
<RecordPeekProperty label="Stage">Interview</RecordPeekProperty>
</RecordPeek>
</>
);
}Popup
A floating panel tied to the button that opened it: a short form, a picker, a filter. It closes on Escape or a click outside, and the page stays live around it.
Tooltip
A short label that appears on hover or focus: the name of an icon button, its shortcut, or the full text of something clipped. It never holds anything the person must read to finish a task.