@seekalfred/primitives
v0.5.1
Published
Accessible, styled primitives built on React Aria Components.
Downloads
496
Readme
@seekalfred/primitives
Accessible, styled primitives built on React Aria Components.
pnpm add @seekalfred/primitives @seekalfred/styles// Ours first, yours after. That order is the whole override story.
import "@seekalfred/styles/base.css";
import "@seekalfred/primitives/styles.css";
import { Button } from "@seekalfred/primitives";
<Button variant="primary" size="md" onPress={save}>
Save changes
</Button>;You do not need Tailwind installed. This package ships compiled CSS.
Accordion
import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger } from "@seekalfred/primitives";
<Accordion defaultExpandedKeys={["billing"]}>
<AccordionItem id="billing">
<AccordionTrigger icon={<ChevronDown aria-hidden />}>Billing</AccordionTrigger>
<AccordionPanel>Invoices and payment methods.</AccordionPanel>
</AccordionItem>
</Accordion>;AccordionTrigger renders inside a heading (level 3 by default, headingLevel
to change it) because screen-reader users navigate an accordion by heading.
Migrating from the legacy shadcn Accordion
Expansion is keyed rather than string-valued. That is the change that touches every call site:
| legacy | Alfred | |
| --------------------------- | ----------------------------------- | ----------------------------------------------------- |
| type="single" | omit allowsMultipleExpanded | |
| type="multiple" | allowsMultipleExpanded | |
| value / onValueChange | expandedKeys / onExpandedChange | the handler receives a Set, not a string |
| defaultValue="x" | defaultExpandedKeys={["x"]} | |
| <AccordionItem value="x"> | <AccordionItem id="x"> | |
| collapsible | (drop it) | React Aria's single-expand mode is always collapsible |
| <AccordionContent> | <AccordionPanel> | |
| bundled chevron | icon prop | this library ships no icon set |
Two deliberate behaviour differences:
- Arrow keys no longer move between headers. Radix implemented Home/End and the arrows; React Aria does not, and the ARIA APG treats it as optional. Tab reaches every trigger.
- The panel does not animate. React Aria hides a collapsed panel with the
hiddenattribute (display: none), which CSS cannot transition out of. The chevron still rotates.
Alert
<Alert variant="destructive" icon={<WarningIcon aria-hidden />} title="Save failed">
Your changes were not saved.
</Alert>role="alert" is announced the moment it renders, so this is the wrong
component for a notice that is simply present on load.
Migrating from the legacy shadcn Alert
| legacy | Alfred | |
| ------------------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
| <AlertTitle>Text</AlertTitle> | title="Text" | was always <h5>; headingLevel now sets it |
| <AlertDescription>Text</AlertDescription> | children | |
| <AlertCircle /> as a child | icon={<AlertCircle aria-hidden />} | the legacy [&>svg] selectors only worked when the icon came first, and did not mirror in RTL |
| variant="default" \| "destructive" | unchanged | |
| native title attribute | (unavailable) | the name is used for the heading |
No success / warning / info variants yet — one faked call site is below
the Rule of Two. Until then, a className override is the supported route;
consumer CSS always wins.
AlertDialog
<AlertDialog isOpen={open} onOpenChange={setOpen}
title="Delete 3 recordings?"
description="They will be removed for everyone and cannot be restored.">
<DialogFooter>
<Button slot="close" variant="ghost">Cancel</Button>
<Button variant="destructive" onPress={remove}>Delete</Button>
</DialogFooter>
</AlertDialog>A preset over Dialog, not a second implementation. It forces
role="alertdialog", no close button, and isDismissable={false}, and it
makes description required — React Aria associates the description only
for that role, so a confirmation built from a plain Dialog loses the
sentence explaining what is about to happen.
Migrating from the legacy shadcn AlertDialog
| legacy | Alfred | |
| -------------------------- | ------------------------------- | --------------------------------------------------- |
| <AlertDialog open …> | <AlertDialog isOpen …> | |
| <AlertDialogContent> | delete | AlertDialog is the content |
| <AlertDialogHeader> / Title / Description | title / description props | description is required |
| <AlertDialogCancel> | <Button slot="close"> | |
| <AlertDialogAction> | <Button variant="destructive"> | pick the variant explicitly |
| <AlertDialogPortal> / Overlay / Trigger | no equivalent | never rendered externally |
AlertDialogAction is deliberately gone. It defaulted to the gradient
primary button, and both call sites override it back to destructive by hand
(data-table.tsx:146, calendar-view.tsx:638). A default every caller fights
is not a default.
Avatar
<Avatar src={user.photoUrl} alt={user.name} fallback="JD" size="lg" />
<Avatar alt="" fallback="JD" />Sizes: xs (20px) · sm (24px) · md (32px, default) · lg (40px) · xl (64px).
Migrating from the legacy shadcn Avatar
| legacy | Alfred | |
| -------------------------------------------------- | ----------------------------- | ------------------------------------------------------------------- |
| <Avatar><AvatarImage/><AvatarFallback/></Avatar> | <Avatar src alt fallback /> | one component, three fewer imports |
| className="h-10 w-10" | size="lg" | six ad-hoc sizes became five named ones |
| <AvatarImage src /> with no alt | alt is required | "" for decorative; 15 of 38 legacy uses had none, which fails axe |
Badge
<Badge tone="success" appearance="soft">Live</Badge>
<Badge tone="neutral" appearance="outline" shape="pill" size="sm">Draft</Badge>Axes: tone (neutral · primary · success · warning · danger · info),
appearance (solid · soft · outline), size (sm · md · lg) and
shape (rounded · pill). Defaults: neutral, soft, md, rounded.
This absorbed the product's Pill. Two components shipped side by side for
one job — 22 consumer files each — and disagreed about shape, size and naming;
pill accepted blue and info, green and success, for one colour.
Two axes rather than one, because six tones by three appearances is eighteen
variant names otherwise. softPrimary next to primaryOutline is that cross
product written out by hand and abandoned halfway.
| legacy | Alfred |
| --- | --- |
| variant="default" | tone="primary" appearance="solid" |
| variant="secondary" | tone="neutral" appearance="solid" — now grey, not lavender; see below |
| variant="outline" | tone="neutral" appearance="outline" |
| variant="softPrimary" | tone="primary" appearance="soft" |
| variant="primaryOutline" | tone="primary" appearance="outline" |
| variant="destructive" | tone="danger" appearance="solid" |
| <Pill tone="slate"> | tone="neutral" shape="pill" |
| <Pill tone="blue"> / "green" | tone="info" / tone="success" |
tone="neutral" appearance="solid" fills with muted-foreground, not
secondary. --color-secondary resolves to periwinkle.400, so a badge asking
for neutral rendered lavender — caught in a browser, because the story
compared the fill against the same wrong token it was painted with. The product
fills its secondary badge with that periwinkle, which is the brand's second
colour rather than an absence of one; those 9 call sites become grey, which is
what they were asking for.
Worth knowing when mapping colours across: our alfred.periwinkle ramp is
the product's --primary-* scale, value for value — periwinkle.50 is
#F3F3FF, .100 is #E7E7FF. The app's --primary (orange) and its
--primary-N (periwinkle) are two unrelated things sharing a prefix.
No asChild — zero call sites, and the legacy [a&]:hover: rules existed only
for the anchor it produced.
Breadcrumbs
<Breadcrumbs>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem href="/design-thinking">Design Thinking</BreadcrumbItem>
<BreadcrumbItem>User Personas</BreadcrumbItem>
</Breadcrumbs>The last child is the current page: give it no href and React Aria marks
it aria-current="page", renders it as a <span role="link"> and takes it out
of the tab order. Separators are drawn by every non-last crumb, so there is
nothing to interleave.
Migrating from the legacy shadcn Breadcrumb
Six parts become two. All but the first rename are deletions, so a call site shrinks rather than moving.
| legacy | Alfred | |
| --------------------------------------- | ------------------------------- | --------------------------------------------------------------------------- |
| <Breadcrumb> | <Breadcrumbs> | mechanical rename; still renders the <nav> landmark |
| <BreadcrumbList> | delete | Breadcrumbs is the <ol> |
| <BreadcrumbItem><BreadcrumbLink href> | <BreadcrumbItem href> | the link merges into the item |
| <BreadcrumbPage>Foo</BreadcrumbPage> | <BreadcrumbItem>Foo | drop the href; current-ness is computed, not declared |
| <BreadcrumbSeparator /> | delete | drawn automatically between crumbs |
| <BreadcrumbEllipsis /> | no equivalent | zero call sites |
| asChild | no equivalent | zero call sites |
Needs a decision per call site: an icon-only crumb. Both legacy call sites
render <BreadcrumbLink href="/"><Home /></BreadcrumbLink> with no text and no
label, which is an axe link-name violation. Add aria-label to the
BreadcrumbItem.
Two smaller deliberate changes: the separator is / rather than a lucide
ChevronRight (this package ships no icon dependency, and all four legacy
separators were the default), and hovering a crumb underlines it as well as
darkening it — colour alone disappears under forced-colors.
Button
| variant | |
| ------------------- | --------------------------------------- |
| primary (default) | solid brand orange, ink label |
| secondary | solid brand periwinkle, ink label |
| outline | background fill, brand border and label |
| ghost | transparent until hovered |
| destructive | solid destructive |
| link | text only, underlines on hover |
The primary label is ink, not white. Brand orange carries white at only
2.44:1; ink clears it at 8.35:1, which is the brand system's own rule. For the
same reason link and outline use primary-strong — a darkened tone of the
brand hue — because primary itself cannot legally be text or a border.
Sizes: sm (32px) · md (36px, default) · lg (40px) · icon (36px square).
size="icon" has no visible text, so it must carry an aria-label. Without
one it has no accessible name and fails axe's button-name rule — the
IconOnly story exists to keep that true.
buttonVariants is exported. Use it when you need button styling on an element
this component cannot render:
import { buttonVariants } from "@seekalfred/primitives";
<a href="/reports" className={buttonVariants({ variant: "outline" })}>
View reports
</a>;Migrating from the legacy shadcn Button
The variant and size vocabulary is unchanged, so most call sites move by
changing the import alone. Five things differ, because this Button is built on
React Aria Components rather than a bare <button>:
| legacy | Alfred | |
| -------------------------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| onClick | onPress | React Aria omits onClick outright. onPress also covers touch and keyboard, and does not fire on right-click. |
| disabled | isDisabled | |
| variant="default" | variant="primary" | default named nothing. |
| size="default" | size="md" | Same 36px height. |
| <Button asChild><a /></Button> | <a className={buttonVariants({ variant })}> | React Aria's Button never renders an anchor. A real Link primitive lands in Phase 1. |
The first four are mechanical. asChild is not — each site needs a decision
about whether it wanted a link or a button.
Two things you gain: a data-[pressed] state the original never had, and
isPending, which blocks activation while staying focusable and announces
itself to screen readers.
Two things that changed on purpose:
- Focus is an
outline, not aring. The original used a 3px box-shadow. Box-shadows are discarded under Windows High Contrast, taking the only focus indication with them, and axe has no rule that catches it. - No
dark:variants. Theming happens in the tokens, under[data-theme="dark"]. Everydark:class in the original is gone, not reimplemented.
Calendar
Maturity: experimental. The API may still move; DateRangePicker is its
first real consumer and will shape it.
import { parseDate } from "@internationalized/date";
<Calendar label="Report date" value={date} onChange={setDate} clearable />
<CalendarRange
label="Period"
visibleMonths={3}
selectionAlignment="start"
defaultValue={{ start: parseDate("2026-09-01"), end: parseDate("2026-09-30") }}
onChange={setRange}
/>Values are DateValue, not Date. A calendar day has no time zone, so
modelling it as a timestamp is what costs you a day at a DST boundary. ADR 0006
makes @internationalized/date the one allowed public type dependency for
exactly this reason, and it is a peerDependency — install it alongside.
String(value) gives you YYYY-MM-DD for a query parameter, so serialize at
your own API edge rather than having the library guess.
label is required. The root is role="application", which has no implicit
name. Pass labelHidden when the surrounding UI already names it.
clearable works controlled and uncontrolled. The Clear button reads React
Aria's calendar state from context and calls setValue(null), which runs
through useControlledState — so onChange fires either way and there is no
onClear to wire up. It inherits isDisabled and isReadOnly from the
calendar, and keeps focus rather than dropping it to <body> when it clears.
selectionAlignment matters for multi-month. React Aria defaults to
"center", which for visibleMonths={3} puts the focused month in the middle —
so a September-focused window opens on August. Range pickers usually want
"start". It is inherited from React Aria rather than defaulted here, because
either alignment is legitimate.
Loading the tokens is not optional
@seekalfred/primitives/styles.css defines no token values — not
--color-primary, not --density-control-h-sm. It only references them (ADR
0003, reference mode), so a consumer that does not load the token layer gets a
component styled against variables that resolve to nothing:
import "@seekalfred/styles/base.css"; // <- the tokens live here
import "@seekalfred/primitives/styles.css";That applies to every component, but Calendar makes it visible fastest. An app
supplying a different token set will find some names coincide and others do
not — and the two that hurt are --focus-ring-width and --focus-ring-offset,
because their absence removes the focus indicator rather than merely shifting a
colour. apps/smoke is the worked example of a correct consumer.
Card
<Card>
<CardHeader>
<CardTitle>Learning Progress</CardTitle>
<CardDescription>Across all active courses.</CardDescription>
</CardHeader>
<CardContent>Twelve of eighteen modules complete.</CardContent>
<CardFooter>Updated 2 minutes ago</CardFooter>
</Card>Padding is block-only on the shell and inline-only on the sections, which
is what lets a divider or a full-bleed image run edge to edge. A single p-6
on the shell would make that impossible.
Migrating from the legacy shadcn Card
| legacy | Alfred | |
| ------------------------ | -------------------------------- | ----------------------------------------------------------------------- |
| <CardTitle> (a div) | <CardTitle> (an <h3>) | check your heading order; pass level if h3 is wrong for the page |
| <CardAction> | no equivalent | zero call sites; put the action in the header or footer |
| [.border-b] / [.border-t] padding | no equivalent | zero call sites; set the padding you want |
| @container/card-header | no equivalent | nothing queried it |
Everything else is a straight rename — Card, CardHeader, CardDescription,
CardContent and CardFooter keep their names, props and slots.
CardTitle is the one change that needs a human per call site. It now
renders a real heading, because none of the 19 legacy call sites wrapped one
and a div title is invisible to screen-reader heading navigation. level
changes semantics only, never the rendered size.
A clickable card is still your problem. Card spreads native div props,
so onClick works exactly as it did — and a div with a click handler is not
focusable and has no role. One legacy call site does this. Use a Button or a
link inside the card instead.
Checkbox
<Checkbox defaultSelected>Email me about new courses</Checkbox>
<Checkbox isIndeterminate onChange={setAll}>Select all</Checkbox>
<Checkbox aria-label="Select row" />The label is children, and that is also how the control gets its accessible
name. Pass aria-label instead only for a genuinely label-less checkbox, such
as a table row selector.
Migrating from the legacy Checkbox
The legacy component had zero call sites, so nothing here breaks. It is listed for completeness, because the shape of the change is the same one the other form controls will take.
| legacy (native input) | Alfred | |
| --------------------- | ----------------- | ------------------------------------------------ |
| checked | isSelected | |
| defaultChecked | defaultSelected | |
| onChange(event) | onChange(boolean) | you get the value, not an event |
| disabled | isDisabled | |
| required | isRequired | |
| (no way to set it) | isIndeterminate | indeterminate is a DOM property, not an attribute |
| (no label at all) | children | the legacy control had no accessible name |
Dialog
<Dialog isOpen={open} onOpenChange={setOpen}
title="Delete user"
description="This removes their account and cannot be undone.">
<DialogFooter>
<Button slot="close" variant="ghost">Cancel</Button>
<Button variant="destructive" onPress={remove}>Delete</Button>
</DialogFooter>
</Dialog>title is required, so a dialog always has an accessible name. React Aria
portals the overlay to document.body, so tests and queries must reach it
through document.body or screen, never through the story container.
Migrating from the legacy shadcn Dialog
Four parts become two props.
| legacy | Alfred | |
| ------------------------- | ------------------------- | --------------------------------------------------------- |
| <Dialog open onOpenChange> | <Dialog isOpen onOpenChange> | the props move onto the panel itself |
| <DialogContent> | delete | Dialog is the content |
| <DialogHeader> | delete | replaced by the two props below |
| <DialogTitle>x</DialogTitle> | title="x" | required |
| <DialogDescription>x</…> | description="x" | |
| <DialogClose> | <Button slot="close"> | React Aria's own dismiss wiring |
| <DialogPortal> / <DialogOverlay> | no equivalent | zero call sites; ModalOverlay does both |
| asChild | no equivalent | DialogTrigger takes the trigger element directly |
Needs a decision per call site: LiveQAOverlay.tsx renders a dialog with
no title. It now needs one — pass a visually hidden node if the design has no
room for a heading.
isDismissable defaults to true, matching Radix. React Aria's own
default is false; taking it would have silently stopped all seven legacy
call sites closing on an outside click. An alertdialog sets it false.
Two legacy behaviours are deliberately not carried: the enter/exit animation
(this repo ships no tailwindcss-animate; React Aria exposes data-entering
and data-exiting for whoever adds it), and the footer's flex-col-reverse,
which put DOM order and visual order in disagreement below sm — WCAG 1.3.2.
Input and Textarea
<Input type="email" placeholder="[email protected]" value={v} onChange={(e) => set(e.target.value)} />
<Textarea placeholder="Tell us more" value={v} onChange={(e) => set(e.target.value)} />Both take native props. React Aria's Input and TextArea are the two
components in this library that use disabled / onChange(event) rather
than isDisabled / onChange(value), so all 51 legacy Input call sites and
all 3 Textarea ones migrate with no prop changes at all. They also read
React Aria's field contexts, so wrapping either in a TextField later works
without touching the call site.
They share one surface (fieldSurfaceVariants), so they cannot drift the way
the legacy pair did — different backgrounds, different focus rings, and
invalid styling on only one of them.
Migrating from the legacy Input / Textarea
| legacy | Alfred | |
| ---------------------- | ----------------- | ---------------------------------------------------------- |
| value / onChange | unchanged | still a real DOM event |
| disabled | unchanged | native |
| aria-invalid | unchanged | drives the invalid border with no prop of our own |
| h-9 (36px) | density scale | 46px at the default, matching Button |
| text-base md:text-sm | text-base | one size; 16px avoids iOS Safari zooming on focus |
| min-h-[80px] | min-h-20 | same 80px, from the spacing scale |
| relative + absolute left-3 + pl-9 | startIcon / endIcon | delete the wrapper and the padding — see below |
Sizing a field
<Input size="sm" aria-label="Filter" />
<Select size="sm" label="Status">…</Select>
<Button size="sm">Apply</Button>sm | md | lg, default md, on the same --density-control-* tokens as
Button — so a field, a picker and a button on one rung are the same height.
In the legacy app 36 of 97 Input call sites and 14 of 32 SelectTrigger ones
hand-rolled a height, and a size="sm" Button (167 call sites) sat 10px off
the field it belonged to.
Two things worth knowing:
sizereplaces the native attribute. React Aria inheritssize?: number(a character count) fromInputHTMLAttributes;InputPropsomits it. Set a width withclassName. No legacy call site used it.- Type does not scale with the box. The field stays at 16px on every rung. Below that, iOS Safari zooms the viewport when the field takes focus.
Textarea has no size — it is min-h driven and no call site overrides its
height.
Icons in a field
<Input aria-label="Search" startIcon={<SearchIcon aria-hidden />} />
<Input aria-label="Target" endIcon={<span>days</span>} />The legacy app has 18 hand-rolled adornments — a relative wrapper, an
absolutely positioned glyph and a hand-tuned pl-9 / pr-16. All 18 use
physical properties (absolute left-* appears 33 times, absolute start-*
zero), so all 18 put the glyph on the wrong side under RTL and let the text
run over it. Replacing the wrapper with these props fixes that and puts the
padding on the density scale, so data-density moves the glyph and the text
together.
Both slots are decorative. They are pointer-events: none, so a click on
the glyph focuses the field. An interactive adornment — a password reveal, a
clear button — is not this prop; no call site in the census needed one.
Mark the glyph aria-hidden: the field's label is its accessible name, and a
second name source makes it announce twice.
An invalid field needs a message. The border changes colour, and colour
alone is not a state cue (WCAG 1.4.1). Pair aria-invalid with
aria-describedby pointing at the error text.
Label
<Label htmlFor="email">Email</Label>Inside a React Aria field (Checkbox, and TextField when it lands) the
association is automatic through context — no htmlFor needed.
Migrating from the legacy Label
| legacy | Alfred | |
| ------------------------------- | --------------- | ----------------------------------------------------- |
| htmlFor | htmlFor | unchanged |
| peer-disabled:* | no equivalent | needs peer on a sibling; nothing here supplies it |
| group-data-[disabled=true]:* | no equivalent | comes from the React Aria field instead |
Menu
<MenuTrigger>
<Button>Actions</Button>
<Menu onAction={(key) => run(key)} placement="bottom end">
<MenuItem id="edit">Edit</MenuItem>
<MenuSeparator />
<MenuItem id="delete">Delete</MenuItem>
</Menu>
</MenuTrigger>Migrating from the legacy DropdownMenu
Fifteen exports become four.
| legacy | Alfred | |
| ------------------------------- | ----------------- | --------------------------------------------------- |
| <DropdownMenu> | delete | MenuTrigger owns the state |
| <DropdownMenuTrigger asChild> | <MenuTrigger> | takes the trigger element directly |
| <DropdownMenuContent align> | <Menu placement> | align="end" becomes placement="bottom end" |
| <DropdownMenuItem onClick> | <MenuItem id> plus onAction on the Menu | the key comes from id |
| <DropdownMenuSeparator> | <MenuSeparator> | |
| Group, CheckboxItem, RadioItem, Label, Shortcut, Sub*, Portal | no equivalent | zero call sites each |
React Aria has MenuSection, Header and selection modes ready if any of
the dropped nine turns out to be wanted.
RTL needs an I18nProvider. React Aria's Popover stamps its own dir
from useLocale(), so dir on the document does not reach a portalled
menu.
Popover
<PopoverTrigger>
<Button>Pick a date</Button>
<Popover placement="bottom start">…</Popover>
</PopoverTrigger>It gains an accessible name. The legacy renders role="dialog" with no
aria-label and no aria-labelledby, so both call sites announce an unnamed
dialog. React Aria labels it from the trigger.
| legacy | Alfred | |
| ----------------------------- | ------------------- | -------------------------------------- |
| <PopoverTrigger asChild> | <PopoverTrigger> | takes the trigger element directly |
| <PopoverContent align side> | <Popover placement> | align="start" → "bottom start" |
| <PopoverAnchor> | no equivalent | zero call sites |
Progress
<Progress label="Upload" value={62} />
<Progress label="Loading results" isIndeterminate />label is required. role="progressbar" takes no name from content, and
neither legacy call site supplies one — both are announced as an unnamed
progressbar. Pass labelHidden when the surrounding UI already says it.
The value is clamped: the legacy's translateX(-(100 - value)%) slid the
indicator off-screen for value > 100 while announcing the raw number.
tone (neutral · success · warning · danger · info) colours the fill.
The product's bar is a score bar, not a loading bar — a score of 30 is not
"30% loaded", it is bad, and the fill was the only thing saying so.
showValue prints the value beside the label. React Aria has always formatted
that text for assistive tech; nothing rendered it, so a sighted reader got a bar
and no number. formatOptions reaches it too, so a currency or "3 of 7" needs no
second prop. trackClassName and fillClassName are escape hatches for a colour
no tone covers.
RadioGroup
<RadioGroup value={plan} onChange={setPlan} aria-label="Plan">
<Radio value="free">Free</Radio>
<Radio value="pro">Pro</Radio>
</RadioGroup>Migrating from the legacy RadioGroup
| legacy | Alfred | |
| ----------------------------------- | ------------------------- | -------------------------------------- |
| onValueChange | onChange | |
| <RadioGroupItem value id/> + <Label htmlFor> | <Radio value>label</Radio> | the label moves inside; no id needed |
The label moves inside the item. That is the one rename a call site makes, and it removes the id bookkeeping Radix required.
ScrollArea
<ScrollArea label="Chat messages" className="h-64">…</ScrollArea>React Aria has no equivalent, so this is a plain overflow container plus the
standard scrollbar-width / scrollbar-color properties — no JavaScript.
label is required, and it is keyboard-focusable. The legacy has no
tabIndex anywhere, so all 17 of its scroll areas are unreachable without a
pointer (WCAG 2.1.1). A focusable element needs a role, and a role needs a
name — the three go together.
ScrollBar and orientation="horizontal" are not ported: zero call sites.
What cannot be styled
scrollbar-color takes a thumb and a track colour and nothing else — no
pixel width, radius, hover state or margins. Those need ::-webkit-scrollbar,
which Firefox has never supported.
Select
<Select label="Country" placeholder="Pick one"
selectedKey={code} onSelectionChange={setCode}>
<SelectItem id="gb">United Kingdom</SelectItem>
<SelectItem id="us">United States</SelectItem>
</Select>Four legacy parts collapse into one. The trigger shares
fieldSurfaceVariants with Input, so a select and a text field in the
same form row are the same box — the legacy had h-10 here against
h-9 on the input.
Migrating from the legacy Select
| legacy | Alfred | |
| -------------------------- | --------------------- | ---------------------------------------- |
| <Select value onValueChange> | selectedKey / onSelectionChange | |
| <SelectTrigger> / <SelectValue placeholder> / <SelectContent> | delete | Select is all three; placeholder is a prop |
| <SelectItem value="x"> | <SelectItem id="x"> | |
| SelectGroup, SelectLabel, SelectSeparator, scroll buttons | no equivalent | zero call sites |
label is required. The legacy exports a SelectLabel nobody renders
and passes no aria-label, so 12 of the 15 selects are visually labelled
by a bare <label> with no htmlFor — programmatically unlabelled.
isInvalid, not aria-invalid. A React Aria Select is a button, so there
is no native attribute to derive from — an aria-invalid passed to it is
dropped in silence by filterDOMProps, whose allowlist stops at the labelling
attributes.
Pair it with errorMessage. React Aria marks the root data-invalid and
leaves the button untouched, so without a message the invalid state is colour
only — WCAG 1.4.1. The message is wired into aria-describedby for you.
<Select label="Country" isInvalid errorMessage="Pick a country to continue." />Separator
<Separator />
<Separator orientation="vertical" />React Aria renders an <hr> horizontally and a <div role="separator">
vertically.
Migrating from the legacy Separator
| legacy | Alfred | |
| ------------- | --------------- | ------------------------------------------------------------ |
| orientation | orientation | unchanged |
| decorative | no equivalent | zero call sites; see below |
This changes the semantics of all six existing uses. The legacy defaulted
to decorative: true, rendering role="none" — so every divider in the app
is currently invisible to assistive technology. A visible line between two
groups of content is a separator, and that is now what it reports.
Sheet
<Sheet isOpen={open} onOpenChange={setOpen} title="Filters" side="right">
<DialogFooter>
<Button slot="close" variant="ghost">Cancel</Button>
<Button>Apply</Button>
</DialogFooter>
</Sheet>A preset over Dialog, so it inherits the scrim, the focus trap, Escape,
the close button — and the required title.
Migrating from the legacy Sheet
| legacy | Alfred | |
| ------------------------- | ----------------- | --------------------------------------------- |
| <Sheet open onOpenChange> + <SheetContent side> | <Sheet isOpen onOpenChange side> | one element |
| <SheetHeader> / Title / Description | title / description props | zero call sites in the legacy |
| <SheetClose> | <Button slot="close"> | zero call sites |
| side="top" \| "bottom" | no equivalent | zero call sites; two lines to add |
All three legacy sheets are unnamed dialogs — none renders a
SheetTitle. title is required now, so that is no longer expressible.
A width bug the port fixes: the legacy panel is w-3/4 sm:max-w-sm,
and sm:max-w-sm names --container-sm, which this repo does not define.
It would compile to nothing and leave the sheet at 75% of the viewport on
every screen. The width here is a spacing-scale step that resolves.
side is logical: in RTL the sides swap.
onBack adds a back control above the heading — for a panel that goes a level
deeper by replacing its contents rather than stacking a second modal, where
escape and browser-back both close the whole thing instead of returning one
level. It renders above the heading, not inside it: a button inside the <h2>
React Aria names the dialog from would make the accessible name "Back Filters".
The body is a ScrollArea, so the heading and back control stay put while
content moves, and the scrolling region is keyboard-reachable — a plain
overflow-y-auto div fails WCAG 2.1.1, which axe caught here and which the
product's own SidePanelBody still ships. scrollResetKey scrolls it back to
the top when the contents change.
Skeleton
<Skeleton className="h-4 w-32" />Sized entirely by the caller, as all 33 legacy call sites do. aria-hidden,
because a placeholder is not content — put aria-busy on the region being
replaced.
The pulse is gated behind motion-safe:.
Slider
<Slider label="Input volume" defaultValue={40} />label is required — all four legacy call sites are unnamed sliders.
The thumb is 24×24 (WCAG 2.5.8); the legacy's was 16. aria-valuetext comes
free, so a scrubber can announce a time instead of a raw second count.
Switch
<Switch isSelected={on} onChange={setOn}>Email notifications</Switch>
<Switch aria-label="Mute" isSelected={muted} onChange={setMuted} />Migrating from the legacy Switch
| legacy | Alfred | |
| ------------------ | ------------- | ----------------------------------------------------- |
| checked | isSelected | |
| onCheckedChange | onChange | still (boolean) => void |
| disabled | isDisabled | |
| (no label slot) | children | a Radix Switch with no aria-label had no name |
The track colours are not the legacy's, and could not be. A white thumb
on --color-primary is 2.44:1 and on --color-muted is 1.13:1 — both fail
WCAG 1.4.11. The track is border unchecked and primary-strong checked,
which clears 3:1 in both themes.
Table
import { Table, createColumns } from "@seekalfred/primitives";
const { column } = createColumns<Channel>();
const columns = useMemo(
() => [
column({ id: "name", header: "Channel", accessor: (r) => r.name }),
column({
id: "cost",
header: "Cost",
accessor: (r) => r.cost,
format: (v) => currency(v),
align: "end",
sortable: true,
}),
],
[],
);
<Table aria-label="Channels" data={rows} columns={columns} getRowId={(r) => r.id} />;aria-label is required: React Aria's Table renders no <caption>, so there is
no other source for the accessible name.
Sorting works without wiring. sortable: true is the whole setup — the
table holds the descriptor and reorders data itself, and aria-sort comes
from React Aria. Pass sortDescriptor to take control instead, which is what
server-side sorting needs; then you own the row order.
Pagination needs no state either. pageSize renders the bar and holds the
page. Sorting is applied to the whole set before the page is sliced from it,
so "cost ascending" surfaces the cheapest row in the data rather than the
cheapest of the rows you happened to be looking at. Its labels are props
(pageLabels) with English defaults — a known debt until useMessages() exists.
The bar is the product's shape: page numbers on the leading edge — first and
last always reachable, the gap between them an ellipsis — and rows-per-page on
the trailing edge as a Select, once you pass pageSizeOptions. A size the
reader chooses outranks pageSize from then on, and onPageSizeChange tells
you about it. The current page announces itself through its accessible name
rather than aria-current, because React Aria's Button runs its props through
filterDOMProps and drops anything outside the labelling set.
Density is not a prop. Cell padding reads --density-row-pad-*, so
data-density="compact" on any ancestor compresses the table along with the
rest of the screen.
The card is the component. The table renders inside a rounded, bordered
container, because that is where the WCAG 1.4.11 boundary obligation lands.
Row dividers use --color-separator, a decorative hairline, NOT --color-border
— drawn in border a data table reads as a grid of boxes rather than a list
of rows. A chevron appears on each row when onRowAction is set, and a
direction caret on the sorted column; both are aria-hidden decoration.
Row height comes from the density scope, not from the component. For the
product's taller proportions set data-density="spacious" on an ancestor.
Overriding styles. Every element carries a data-slot
(table-scroll, table, table-header, table-column, table-row,
table-cell). Consumer CSS is unlayered and ours is layered, so
[data-slot="table-cell"] { … } wins without !important. For styling that
depends on row data, use rowClassName.
| prop | |
| --- | --- |
| variant | lined (default) · plain |
| Column.align | start · center · end, applied to the header and its cells |
| Column.width | number or %; any width switches the table to table-layout: fixed |
| onRowAction | receives the row, not its key |
| rowClassName | (row) => string |
| empty | rendered instead of rows; no default copy |
| minWidth | px; below it the built-in wrapper scrolls |
| pageSize | rows per page; the bar renders itself, and sorting is applied before the slice |
| pageSizeOptions | adds the rows-per-page select; the reader's choice then outranks pageSize |
| onPageSizeChange | notified after the table has applied the reader's new size |
| isRowHighlighted | (row) => boolean; tints the viewer's own row |
| frame | card (default) · none — off for a table already inside a card |
| headerType | sentence (default) · caps — uppercase with tracking |
| headerFill | subtle (default) · none |
| striped | zebra-stripes alternate rows |
| totalRow | (column) => ReactNode; renders a real <tfoot> |
| Column.numeric | tabular numerals, end-aligned by default |
Using it in alfred-ui
No theme override is needed. Measured against the running product, the two already agree on every value that matters:
| | alfred-ui | this component |
| --- | --- | --- |
| card edge | #e6e6e6, radius 12px | same |
| row divider | #e6e6e6, on the row's TOP | same |
| header label | #6b6b78, 14px, 600 | same |
| cell text | #02021e, 16px, 400 | same |
| cell padding | 16px / 12px | same |
| typeface | Satoshi | same |
The text colours were never a gap: the product's --muted-foreground is
#6B6B78 and its body ink is #02021e, which are this system's
--color-muted-foreground and --color-foreground exactly. An earlier draft of
this section claimed otherwise and prescribed #0f172b / #6b6b6b; those come
from --table-text-*, which the AI-visibility tables do not use. Do not apply
them.
One value differs and it is deliberate: the product fills its header with
--neutral-200 (#fffcfc) where this uses --color-background (#ffffff).
That is three counts of warmth in two channels, invisible on a white card, and
not worth a palette entry. If you want it exactly, override
--color-background for the header only, or say so and it becomes a token.
Migrating from the legacy DataTable
| legacy | Alfred | |
| --- | --- | --- |
| <DataTable>{renderProps => …}</DataTable> | <Table data columns getRowId /> | the component owns the markup; there is no render prop |
| config={{ sorting: { enabled: false }, … }} | (omit) | every feature is opt-in, so there is nothing to disable |
| ColumnDef (TanStack) | Column<T, V> | built with createColumns<T>() so each column keeps its value type |
| meta.align | Column.align | |
| meta.width / <colgroup> / width classes | Column.width | one mechanism instead of three |
| onRowClick(row) | onRowAction(row) | also fires on Enter |
| rowClassName | unchanged | |
| rowIdField="keywordId" | getRowId={(r) => r.keywordId} | |
Row expansion, sticky headers, loading skeletons, pinned columns, the total
row, filtering, column visibility and CSV export are not ported — each had
one consumer or none across the six legacy tables, and they belong to a future
DataGrid.
Tabs
<Tabs defaultSelectedKey="overview">
<TabList aria-label="Sections">
<Tab id="overview">Overview</Tab>
<Tab id="activity">Activity</Tab>
</TabList>
<TabPanel id="overview">…</TabPanel>
<TabPanel id="activity">…</TabPanel>
</Tabs>Migrating from the legacy Tabs
| legacy | Alfred | |
| ----------------- | ----------------------- | ------------------------------------------ |
| value | selectedKey | |
| defaultValue | defaultSelectedKey | |
| onValueChange | onSelectionChange | |
| <TabsTrigger value="x"> | <Tab id="x"> | paired to the panel by id |
| <TabsContent value="x"> | <TabPanel id="x"> | |
| disabled | isDisabled | |
Only the selected panel is in the DOM. Radix kept every mounted panel
and hid the inactive ones; React Aria renders one. A panel holding
uncommitted form state, a running video or a scroll position will lose it
on tab change — pass shouldForceMount on that TabPanel. With 31 legacy
panels, some will need it.
The selected tab carries a border, not just a fill: bg-background on
a bg-muted list is 1.13:1, and WCAG 1.4.11 wants 3:1 for the thing
telling you which tab is active.
Toast
// once, near the root
<ToastRegion label="Notifications" />
// anywhere, including outside React
toast({ title: "Report saved" });
toast({ title: "Upload failed", description: err.message, tone: "danger" });Experimental. React Aria ships this API as UNSTABLE_Toast* in 1.21.1, so a
minor bump upstream can reshape it. The alternative was hand-rolling the live
region, queue, timers and focus behaviour — which is how toast implementations
acquire their accessibility bugs.
The queue is a module singleton, so toast() is importable from anywhere: the
product calls it from API error handlers and effects with no component to hang a
hook off, and a context-based queue would turn 67 call sites into 67 refactors.
Two legacy defects are not ported. TOAST_LIMIT = 1 meant a second toast
replaced the first, so a page reporting three failures showed one;
maxVisibleToasts is 3 here. TOAST_REMOVE_DELAY = 1000000 — sixteen minutes —
meant nothing auto-dismissed; timeout defaults to 5s. React Aria refuses a
timeout under 5000ms and pauses it while the region is focused, because a message
that vanishes before it can be read fails WCAG 2.2.1. Pass 0 for an error the
user must act on.
tone matches Badge's vocabulary. The legacy had default and destructive
only, so 38 of 67 calls passed a variant that could not say "this worked".
Tooltip
<TooltipTrigger>
<Button aria-label="Mute"><MicOff /></Button>
<Tooltip>Mute</Tooltip>
</TooltipTrigger>delay defaults to 0, matching the legacy provider. React Aria ships
1500ms — taking that default would have put a 1.5-second warm-up on every
tooltip in the app. Focus opens it immediately regardless.
| legacy | Alfred | |
| -------------------------- | ----------------- | -------------------------------------------- |
| <TooltipProvider> | delete | 23 call sites; the legacy Tooltip already wrapped itself in one |
| <Tooltip> + <TooltipTrigger asChild> | <TooltipTrigger> | one wrapper, trigger element direct |
| <TooltipContent side> | <Tooltip placement> | |
RTL differs between the two. Popover stamps its own dir from
useLocale(), so it needs an I18nProvider. Tooltip sets no dir at all and
inherits from the document. Set both if you support RTL.
