@seekalfred/primitives
v0.8.1
Published
Accessible, styled primitives built on React Aria Components.
Downloads
2,541
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.
Motion (Accordion)
A panel opens and closes in place with plain CSS: height follows the content on the smooth spring, opacity eases over 240ms on the way open and 160ms on the way shut, and the answer settles down from 6px up with a little blur. The chevron turns on the snappy spring. React Aria marks a collapsed panel hidden, which cannot transition, so the CSS keeps it as a box of no height and takes it out of the accessibility tree with content-visibility: hidden. Height needs interpolate-size (Chromium); elsewhere it snaps open, still correct. The panel's children now sit in an accordion-panel-content wrapper. Nothing animates at mount; reduced motion is instant.
ActionButton
<ActionButton onAction={save} pendingLabel="Saving" successLabel="Saved">
Save changes
</ActionButton>A button for one async commit. Its label morphs letter by letter, from the resting text to a pending one to a success one: letters both share keep their place, new ones rise in from a soft blur, removed ones lift away, and the width springs to fit. The arrow becomes a spinner and then a tick that draws itself.
onAction is awaited. A rejection returns the button to rest and goes to
onActionError (without it the error is dropped, because the button has
already returned to rest). It stays focusable while pending (aria-disabled,
aria-busy), a live region announces the pending and success labels, and the
accessible name is always the resting label. variant and size are Button's.
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.
Motion (Alert)
A string title or description swaps as a whole when its text changes (the new text rises in, the old lifts away); a different icon grows in from small and blurred; the copy column springs to its new height. New optional open and onDismiss props make the alert come and go: hiding it collapses its height with the fade (the spring, opacity 240ms in and 160ms out), and onDismiss adds a Dismiss button (dismissLabel). An uncontrolled alert (onDismiss without open) collapses first and then reports; with open, the owner decides. An alert open on first render does not animate, and one with neither prop is the same DOM as before.
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.
Rich content: body
body puts rich content between the description and the actions: a plan
comparison, a before/after diff, a quoted reply, a field to fill in or a choice
to make (a RadioGroup). It shares the description's edge and is in the
dialog's tab order before the actions. false, true and "" (as from
cond && <X />) render no body.
<AlertDialog
isOpen={open}
onOpenChange={setOpen}
title="Cancel subscription?"
description="You keep Pro until the end of the period, then move to Free."
body={<PlanSummary plan={plan} />}
>
<AlertDialogCancel>Keep plan</AlertDialogCancel>
<AlertDialogAction onPress={cancel}>Cancel subscription</AlertDialogAction>
</AlertDialog>The body is not part of the accessible description: a screen reader announces
only the description sentence with the title, and reads the body in place or
as focus reaches its controls, each with its own label. So a field or a choice
belongs in body, but the description must say everything needed to answer,
including that something below is required ("Pick what happens to your
recordings, then confirm.").
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 |
Motion (Avatar)
A photo that is still downloading after mount fades in and un-blurs over 240ms; one already decoded shows at once. The initials fade in over 240ms while a photo loads or after it fails. New optional presence ("online" | "offline") draws a dot on the corner that grows in on the snappy spring, swaps when it changes and shrinks away; its word is read beside the name. The root no longer clips (the dot sits on its edge); the photo clips itself to the circle.
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.
Motion (Badge)
A new string label swaps as a whole and the pill's width springs to the new size on the morph spring; colour, border and fill ease over 240ms. New optional icon swaps in place when it changes. The label now sits in a wrapper inside the badge, so query the root through [data-slot="badge"], not through its text. The reference's hover tint is not ported.
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.
Motion
A crumb added after the first render slides in 8px from the left over 240ms and the crumbs that stay slide to their new place; link colour eases over 160ms. The trail present at first render is still. A removed crumb disappears at once (it would need a leaving copy), and the reference's underline fade is not ported: Alfred's links carry no underline at rest by design.
Wrapping (Badge)
wrap lets the label break onto more lines, for a tag in a table cell or a narrow column. The badge shrinks to its
container, breaks long unspaced words, starts the text at the leading edge and grows in height. Off by default and then
a badge is one line that never shrinks, as before. Prefer shape="rounded" for long labels: a multi-line pill has
very round ends. The width does not spring to a new label while wrap is on.
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 |
| ai | orange gradient, ink label, wand icon; generative actions only |
| 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.
ai is for generative actions only (Generate, Summarise, Seek Alfred) and
nothing else. Primary stays flat; the gradient belongs to ai alone. The button
brings its own wand: pass startIcon={false} for none, or your own node in
startIcon to replace it (never as a child, which adds a second icon). Disabled
swaps the gradient for the muted fill. On keyboard focus its own border turns
ink instead of drawing a ring (the outline returns under forced-colors).
Sizes: sm (32px) · md (36px, default) · lg (40px) · icon (36px square) · icon-control-sm (square at the sm height) · icon-sm (24px square). These pixel values predate density tokens; see "Compact icon button" below for the current icon sizes.
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.
Give it an href and it is a link: a real <a>, announced as a link, with the
same classes and the same hover, press and focus-ring states. Use it whenever
activating the control navigates.
<Button href="/reports" variant="outlineNeutral">
View reports
</Button>For client-side routing, mount React Aria's RouterProvider once above it;
without one an href is an ordinary page load, which is what an external or
download link wants anyway. target, rel and download pass through.
buttonVariants is still exported, but it is no longer the way to style a
link. On your own <a> its hover, press and focus classes never apply: they key
on React Aria data attributes a plain anchor never receives, so keyboard focus
is invisible.
Motion
A Button moves, and every motion is pure CSS plus a little plain React where CSS cannot reach (no animation library):
- Press. It scales down while pressed (0.97; 0.96 for an icon button; 0.985
above 220px wide) and springs back. Menu and popover triggers
(
aria-haspopup,role="combobox",data-state), disabled and pending buttons never scale. - A changing label. A different text, or a different icon, crossfades: the
old one is popped out of flow where it was,
aria-hidden, and lifts away while the new one rises in, and the width springs to fit. Nothing animates on first render. While it runs the OUTGOING label is still in the DOM, so read a button's text after the change settles, and keepids, inputs and anything stateful out of a label that changes. isPending. A spinner scales in over a label that fades; the button stays focusable (aria-disabled) and announces itself (aria-busy).- Hover and press on
primarystep its opacity (0.91, 0.84) and every filled and outlined variant gains a resting shadow on hover. A disabledprimaryfades; the other variants stay opaque and mute their colours.
Reduced motion keeps the opacity change and drops the movement and blur. The
spring easings are linear() curves (Chrome 113, Safari 17.2, Firefox 112);
older browsers get a cubic-bezier with the same overshoot.
The @seekalfred/styles motion tokens behind it are --ease-* and
--duration-* (settle, enter, snappy, morph, gentle, calm,
smooth, quick, instant, …).
Compact icon button and wrapping label (Button)
size="icon-control-sm" is the icon-only button for an sm row: a square at the sm control height (36px in comfortable
density), with the same --icon-sm glyph an sm button uses. It is on the density scale, so it grows and shrinks with
sm and a toolbar of sm buttons and icon buttons stays level. Use it instead of icon, which is square at the md
height (taller, 44px comfortable) and stands taller than its sm neighbours, and instead of sm with a glyph, which is about
50x36 because of its 16px side padding. The name says what it matches (the sm control height) because icon-sm was
already taken by the 24px compact square below. Same rules as size="icon": the glyph is children and the button must
carry an aria-label.
size="icon-sm" is a 24px square icon-only button, for a glyph that sits beside text (an info trigger next to a
heading). 24px is the WCAG 2.5.8 target minimum, so the 14px glyph stays hittable. It is fixed, not on the density
scale. Same rules as size="icon": the glyph is children and the button must carry an aria-label.
wrap lets the label break across lines, for long text that is not ours (a prompt in a list, a name in a cell). The
button shrinks to its container, breaks even long unspaced words, starts the text at the leading edge, and grows
taller than size, whose height becomes its minimum. Add className="justify-start" to start a link button at the
edge of its row. Off by default, and then a button is one line at a fixed height as before. A wrapping button's
width does not spring when its label changes.
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> | <Button href> | Renders React Aria's Link, so the states still work. Needs RouterProvider for client-side routes. |
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.
ButtonGroup
<ButtonGroup
label="Document actions"
items={[
{ id: "edit", label: "Edit", icon: <PencilIcon aria-hidden /> },
{ id: "share", label: "Share", reserve: ["Copied"], onSelect: copyLink },
]}
menu={{ label: "More actions", items: [{ id: "del", label: "Delete", destructive: true }] }}
/>Related actions joined into one surface: a shared border and radius, hairline dividers, no gaps. One soft highlight glides between segments under the pointer or keyboard focus (it appears in place, and only moves between segments travel), the pressed segment answers inside itself, and an optional chevron segment opens more actions in a menu that grows from the group's edge.
A segment's label can change in place without resizing it: reserve lists the
other labels it can show and the segment sizes to the widest. A label that
changes right after its segment is pressed (Share to Copied) is announced
once. When the row does not fit, segments with an icon drop their label and keep
the icon (collapseLabels). Tab visits every segment; the arrow keys along the
orientation, Home and End move between them. variant is outline or
solid, size is sm or md, orientation is horizontal or
vertical.
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.
Motion
Built in plain CSS and a little plain React, with no animation library.
- A one-month calendar shows its month on a strip with the neighbours beside it. Moving by a month slides the strip on a spring; the other months are faded back and
inert. A jump of more than a month (Shift + Page Down) brings the new month in from one month away instead of scrolling through the ones between. The window follows the height of the month in view. - The month title, now in the header beside the buttons, crosses in place: the new month arrives from the side the strip travels to while the old one leaves toward the other. React Aria's own heading stays, visually hidden, as the text.
- In a single-date
Calendar, the selected day's fill is one disc that glides on a straight line between days, grows in when a selection appears in a month and fades out when it leaves. Each number flips colour as the disc passes it: the day it lands on waits longer on long hops, the day it leaves waits longer on short ones. - Day and header buttons fill on hover, press to .94 and .96 and spring back. Nothing animates on first render.
CalendarRangeand calendars with several months keep their layout and get the press and hover motion; the range band has no motion yet. - Reduced motion swaps the slides and glides for plain fades or nothing.
DatePicker rolls its value part by part (a later date rises in, an earlier one drops in, a part that did not change holds still, and parts after a wider or narrower one slide along). The chevron turns while the list is open, the list grows from the field and leaves quicker, and the trigger fills muted while it is open. A pick is seen for 300ms (the disc glides, the footer confirms it) before the list closes; Clear closes at once. The footer gains a status line (selectedStatusLabel, emptyStatusLabel) and takes Clear (clearable, clearLabel) out of the calendar. Reduced motion closes at once and fades.
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.
DateRangePicker
<DateRangePicker label="Invoice dates" value={range} onChange={setRange} />A trigger that grows into the panel it opens: one surface whose width, height,
corners and offset ride springs from the trigger's box to the panel's and back,
while the panel's content arrives just behind it and leaves just ahead of it. The
panel holds a rail of presets (presets, with defaultDateRangePresets from today
back to the start of the year), the typed start and end, and two months side by side
(one month under a scrolling row of presets when there is no room; months forces
either). Nothing is applied until Apply: the panel edits a draft, onChange
fires on Apply, and Cancel or Escape throws the draft away. Values are DateValue
objects, either end may be null, and messages renames every string.
Motion (plain CSS and a little plain React, no animation library; the five springs are sampled from the reference solver):
- The surface springs open on the grow spring and shrinks on a slower one; the trigger's icons and its value step aside as it grows (120ms, a little blur) and come back after it has shrunk; the root stays on top for half a second after closing so the shrinking box never slips under a neighbour.
- The formatted range flies from the trigger into the footer as the surface grows and back into the trigger as it shrinks; its words roll one by one (a later date rises, an earlier one drops, a word that did not change holds still).
- Months slide together in the direction you travel: the new pair arrives from the
side you travel to with a little blur while the old one is kept where it stood and
slides away,
inert. - The highlight is one bar per week under the days, which stretches from the edge the range arrives from as you hover or arrow to the end day, plus a solid end at each side which glides from day to day. A month that has just arrived shows its highlight in place; only a change after that animates.
- One highlight glides between the presets, and the compact row scrolls to keep the active one in view. Days press to .9, buttons to .97 and the header arrows to .96.
The calendar is React Aria's RangeCalendar, so keyboard, locale and calendar-system
behaviour are its own; the bars and ends are measured from the cells it renders.
clearable adds a Clear button in the footer. Reduced motion jumps the surface and
swaps the slides and rolls for plain fades.
Breaking from the earlier DateRangePicker: the panel now edits a draft that
Apply commits (it used to commit when the second day was pressed), the calendar and
the typed fields sit beside a preset rail, and DateRangePicker is its own module
(still exported from /date-picker). It reads the viewer's local today for the
presets, so @internationalized/date (a peer dependency) is imported at runtime.
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.
Featured and flush cards
<Card highlighted>…</Card> {/* the one featured card in a view */}
<Card flush>…</Card> {/* a table, list or chart that meets the top and bottom */}highlighted replaces the hairline with a brand gradient edge (secondary to
tertiary, so it follows the theme) without changing the card's box. To repaint
the edge, set the --card-highlight custom property on the card element itself
(className or style on Card): the utility declares it on the card, so a
value set on an ancestor does not reach it. Use it for one card per view and
say in the content why it is featured: the edge is decoration. flush drops
the shell's block padding and the gap between parts and clips the content to
the rounded border, keeping the outline, radius, surface and shadow; a
className padding still wins. The header and footer keep a 24px block
inset, so their text clears the corner curve; the content stays block-flush.
Every section keeps its inline padding, so text never sits in the corner, and
a section's fill (<CardContent className="bg-muted">) still reaches the side
edges because background paints the padding. Both default to false, and the two
compose: a flush featured card keeps its gradient edge.
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.
Motion (Card)
interactive makes a card respond to a mouse or pen: it floats 2px on the snappy spring while pointed at, its border darkens (160ms) and its shadow deepens (240ms). Touch never lifts, reduced motion does not lift, and a card without interactive never moves. Not ported: a media zoom, a status line and the quick-look morph, which need slots Alfred's card does not have.
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.
Card variant
variant="card" draws one option as a selectable card, the counterpart of
RadioGroup variant="card" for a choice where several can be on. The whole card
is the target, the title is children, and it takes a description, a
decorative icon and media (a thumbnail or preview above the title). The box
sits at the end of the title row.
<Checkbox variant="card" isSelected={on} onChange={setOn} description="gpt-5 · $1.25 in · $10 out per 1M" icon={<CpuIcon />}>
GPT-5
</Checkbox>
<Checkbox variant="card" media={<img src={thumb} alt="" className="aspect-square w-full rounded-md object-cover" />}>
hero-banner.png
</Checkbox>- The title alone is the option's name; the description is its description
(
aria-describedby). The icon isaria-hidden; givemediaits ownalt. - Selected shows twice: a
primary-strongedge and the tick, so it never rests on colour alone. - Focus is the card's own 1px border turning dark, with no ring (as on a text field). Forced colours get an outline back, because they override border colours.
description,iconandmediaare ignored by the default variant, which renders exactly as before.checkboxCardVariantsis exported.- Card options are for choosing (state that is kept). A tile that does something on
press, and closes the dialog it is in, is a Button: radio selection follows the
arrow keys, so a radio card that acts on
onChangewould act on the first arrow press.
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 |
Motion (Checkbox)
The chosen colour grows in from small on the snappy spring while it fades in, the mark draws itself, and the check morphs into the dash and back on the morph spring (the d property transitions in Chromium; elsewhere it swaps). The square (not the hit area) presses to .95 in 100ms and springs back; its border eases over 160ms and darkens on hover while unchosen. DOM: the two glyph svgs are now one checkbox-mark (with data-mark of check or indeterminate) and a checkbox-fill element carries the colour, so the box's own background no longer turns primary.
CodeBlock
<CodeBlock value="npm install @seekalfred/primitives" onCopied={() => toast("Copied")} />
<CodeBlock value={longLine} wrap copyAriaLabel="Copy command" />A snippet of code with a copy button, composed from CopyButton (which owns the copy, its feedback and its announcement).
value is shown and copied; children replaces what is shown (highlighted markup) while value is still copied. By
default a long line scrolls sideways and the scroll area takes keyboard focus (named by codeLabel); wrap breaks
lines instead. copyLabel is the visible button text, copyAriaLabel its name (keep the label inside it),
iconOnly drops the text; onCopied and onCopyError pass through.
CommandPalette
const [open, setOpen] = useState(false);
<CommandPalette
items={[
{ id: "home", group: "Pages", label: "Home", icon: <HomeIcon aria-hidden />, hint: "Go to" },
{ id: "theme", group: "Actions", label: "Switch theme", keywords: ["dark", "light"], shortcut: "mod+shift+l" },
]}
recent={[{ id: "posts", label: "Published posts", hint: "Go to" }]}
isOpen={open}
onOpenChange={setOpen}
onSelect={(item) => run(item.id)}
/>A modal search-and-run list, built on Dialog, Input and ShortcutKeys. It listens for Cmd+K or Ctrl+K
on the document (shortcut, default "mod+k", where mod is either key; shortcut={false} removes the
listener) and toggles itself. The chord's default is prevented, so a browser that binds Ctrl+K does not
take it. useCommandPaletteShortcut(onTrigger, { shortcut, enabled }) is the same listener as a hook, for a
different chord or an app that opens the palette from its own state.
It is controlled with isOpen and onOpenChange, or uncontrolled with defaultOpen. onSelect(item) runs
after the palette has asked to close, so an action that opens another dialog finds focus already returned.
Items. id and label are required; group is the heading (groups appear in the order first seen, items
kept together), icon is decorative, shortcut draws key caps (a hint, it binds nothing), hint is plain
trailing text shown when there is no shortcut, keywords match without being shown, and isDisabled lists an
item that the arrow keys skip and a click ignores. The default filter needs every word of the query to appear in
the label, group or keywords, case-insensitively; filter(item, query) replaces it. recent is a group that leads
while the box is empty and gives way to the search once typing starts.
Keyboard. Arrow keys move the active row and wrap, Home and End jump to the first and last enabled row, Enter chooses, Escape closes. Typing returns the highlight to the first match. Home and End are claimed from the text caret on purpose. Hovering a row makes it active.
Accessibility. The search box is a combobox (aria-expanded, aria-controls, aria-autocomplete="list",
aria-haspopup="listbox") that keeps DOM focus; aria-activedescendant names the active option in the
listbox, and groups are role="group" labelled by their heading. The dialog is labelled by a visually hidden
heading (label, default "Search and commands"). Focus lands in the box on open, is trapped while open, and returns
to the element that had it on close. A polite live region announces the result count, and the empty state is a
status. The enter and exit motion is Dialog's and is off under prefers-reduced-motion; the row highlight
fades only under motion-safe.
Copy. Every string is a prop (label, searchLabel, placeholder, recentLabel, emptyMessage,
resultsLabel, footer); footer={null} removes the key-hint strip.
ConfirmMorph
<ConfirmMorph label="Delete project" onConfirm={deleteProject} onUndo={restoreProject} />
<ConfirmMorph label="Delete project" confirmMode="hold" onConfirm={deleteProject} />A button for destructive or important actions that asks in place. The same pill morphs into a question with Cancel and Confirm, then a spinner, then a result with Undo; its width springs to each face while the faces crossfade at their natural width. Use it where a dialog would be heavy.
confirmMode is press, hold (a press held for holdDuration fills the
button, and drains back if let go early) or countdown (a draining ring and
ticking seconds that Cancel or Escape can still stop). Escape, a press outside,
or confirmTimeout answer no; resting the pointer on it, or a hidden tab,
pauses the clock. A press right after the question appears is the tail of the
opening press, not an answer. A rejection shows an error face with Retry.
Controlled with state and onStateChange. A live region announces each step,
and focus follows the control between faces only when it was already inside.
CopyButton
<CopyButton value={token} />
<CopyButton value={url} iconOnly variant="plain" />Copies a value and says so, calmly: the icon trades places in one soft breath
(copy, then a tick that draws itself, or an alert if the clipboard refused), the
label's changed letters rise in, and the button never changes width because its
label cell reserves the widest of Copy, Copied and Failed. It returns to
rest by itself, a live region announces the result, and the accessible name is
always the resting label.
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.
Motion (Dialog)
The overlay fades in over 240ms and out over 160ms and blurs the page a little; the panel rises 8px and grows from .96 on the smooth spring while it fades in, and leaves over 160ms. A string title or description swaps as a whole when its text changes. The close button fills on hover and presses to .96. While the dialog leaves, the overlay lets clicks through. New entrance prop ("rise" or "none") lets an edge-anchored preset opt out; Sheet does. Reduced motion is a plain fade.
EmptyState
<EmptyState action={<Button size="sm">Clear filters</Button>}>
No invoices match these filters.
</EmptyState>What a list shows when it has nothing to show: one muted line, centred in a
bordered box the width of the list. action is an optional next step under the
text and is omitted from the DOM when absent.
image is an optional illustration above the icon and the title (an <img>,
an inline SVG or any element). It is centred, shrinks to fit a narrow box and
keeps its aspect ratio; its size is the artwork's. The slot reserves no space,
so set the artwork's intrinsic width and height, or the layout jumps when
it loads. The slot does not hide it from assistive technology, so name it:
give an <img> an alt (alt="" when the title already says it all), and an
inline <svg> role="img" with an aria-label, or aria-hidden="true" when
it is decorative. Omitted from the DOM when absent; false (from
cond && <img />) counts as absent.
Motion (EmptyState)
New optional icon settles in once when the state first appears (small and transparent to rest over 480ms); a different icon component later swaps in place. A string message swaps as a whole when it changes and the message column follows its height on the smooth spring. The message now sits in an empty-state-copy frame.
Frame and alignment (EmptyState)
frame="dashed" draws the empty state as a block of its own: a dashed border, rounded corner and the muted surface,
for a placeholder where a chart or panel will go. align="start" puts icon, text and action at the leading edge
(the default center is the page-level empty state); with no icon or image the title also drops its top margin. Both
are off by default. The description keeps EmptyState's own measure (18rem).
<EmptyState frame="dashed" align="start" title="Connect Search Console" description="Rankings appear here once connected." action={<Button>Connect</Button>} />ExpandingButtonGroup
<ExpandingButtonGroup
label="Message actions"
items={[
{ id: "reply", label: "Reply", icon: <ReplyIcon aria-hidden /> },
{ id: "archive", label: "Archive", doneLabel: "Archived", icon: <ArchiveIcon aria-hidden /> },
{ id: "delete", label: "Delete", tone: "danger", icon: <TrashIcon aria-hidden /> },
]}
/>A compact pill of icon buttons. One action always shows its label; the one you
point at or focus takes over the label while its neighbours slide aside, on a
spring. Moving between actions needs the pointer a few pixels inside the next one,
so resting on a boundary never flips between them. An action can confirm in place
(doneLabel): the icon draws a check and the label becomes a past tense word
without the width changing. A promise from onSelect keeps the button busy.
On touch, the first tap reveals the label and a second tap runs the action. A disabled action stays focusable and still reveals its label. Arrow keys, Home and End rove focus.
FileDropZone
<FileDropZone
label="Contract"
prompt="Drop a contract here"
hint="PDF only, up to 20 MB."
acceptedFileTypes={["application/pdf"]}
onFiles={(files) => upload(files[0])}
/>A dashed drop area with a "Choose file" button, on React Aria's DropZone and
FileTrigger. The browser's own file control ("Choose Files / No file chosen")
is never shown: the real input is visually hidden and the button opens it, so
Tab reaches the button and Enter or Space opens the dialog. hint is where the
accepted types and the size limit go, stated before a file is chosen.
acceptedFileTypes and allowsMultiple reach the dialog; a drop is not
filtered, so the consumer decides what it can read. Without allowsMultiple
only the first file is handed over.
Motion (FileDropZone)
The surface eases its fill over 240ms and its edge over 160ms. With a file over it, the zone breathes (swells 1.2% and settles over 2.2s, repeating) and light follows the file: a wash inside and a brighter stretch of the edge nearest it, which lean toward the pointer on a short ease (React Aria's onDropMove gives the position). New optional dropPrompt swaps in for the prompt while a file is over. Reduced motion does not breathe. Not ported: the upload list, progress bars, retry, thumbnails, paste, the compact prompt, the sheets illustration and the dashes closing into a solid edge.
FilterBar
<FilterBar filters={filters} inline={<Input aria-label="Search" />} />A list toolbar: every dropdown filter sits behind one Filters button with a count badge, and each set filter shows as a removable chip with Clear all.
Motion
A chip added after the first render opens its slot from nothing while it fades up from a blur and scales in, so the chips beside it glide over; a removed chip stays while it fades and shrinks over 120ms and its slot collapses on a spring; a changed value swaps its text and the chip's width springs to it; Clear all comes and goes in its own slot; and the row follows its height when a chip makes a line wrap. Nothing animates at first render, and a removed chip is aria-hidden and inert while it leaves. The slot widths animate from and to auto, which needs interpolate-size (Chromium); elsewhere they snap, still correct. Not ported: the Add-filter button that grows into a field-and-value menu (Alfred's Filters button opens a popover of selects, which gets the Popover motion below).
FloatingButtonGroup
<FloatingButtonGroup
label="Board actions"
items={[
{ id: "undo", label: "Undo", icon: <UndoIcon aria-hidden />, iconOnly: true, shortcut: "⌘Z" },
{ type: "separator" },
{ id: "present", label: "Present", isPressed: presenting, onSelect: toggle },
]}
/>Separate soft buttons in a quiet tray (a role="toolbar"). One shared highlight
morphs, in position and size, to whichever button the pointer or keyboard focus is
on, fades when the pointer leaves, and settles darker and a touch smaller while a
button is pressed. Arrow keys move focus (skipping disabled buttons), Home and End
jump to the ends, and only one button is a tab stop.
reserveLabels lists the other labels an item can show, so a label change
(Share to Copied) never moves anything. Icon-only items put their label,
and any shortcut hint, in a tooltip. isPressed marks a mode toggle.
variant is muted or floating (raised, for an overlay).
IconMorph
<Button aria-label="Menu" aria-pressed={open} onPress={toggle}>
<IconMorph name="menu" state={open} />
</Button>Icons that morph between two states instead of swapping: menu to close, play
to pause, plus to check, copy to check, theme (sun to moon), chevron
(turned), bell (notifications on) and heart (liked). Lines rotate and slide,
shapes interpolate their outlines, and two carry a moment of their own: the bell
rings when it turns on, and the heart bursts when liked. Every morph rides an
interruptible spring, so toggling quickly reverses from wherever the icon is. The
icons are decorative: put them in a button that carries the name and
aria-pressed. Each is also exported on its own (MenuIcon, HeartIcon, …).
InfoTooltip
<InfoTooltip label="More about Visibility score" content="Share of answers that mention you." />A small "i" button that explains something in a tooltip: the compact icon-sm Button and a Tooltip. It is a real
button, so the help is reachable by keyboard as well as hover. label is its accessible name (default "More
information"); say what the help is about, because several triggers on a page otherwise read the same. placement,
className (button) and tooltipClassName (bubble) are the only other props. It is new to the library: the app's own
InfoTooltip wraps the app's Tooltip with a fixed label.
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 |
| hand-rolled <label> + helper + error text | label / description / error | optional; see below |
| 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, or use the error prop below,
which does both for you.
Label, description and error
<Input
label="Email"
description="We only use it to send receipts."
error={touched && !valid ? "Enter a valid email address." : undefined}
value={v}
onChange={(e) => set(e.target.value)}
/>Input and Textarea take three optional props. label is a ReactNode
shown above the control. description (helper text) and error are plain
strings, because the library animates their copy word by word. An empty or
undefined error closes the error row.
Passing any of the three wraps the control in a field wrapper, even when the
value is undefined. Passing none leaves the DOM exactly what it was before
these props existed: the bare control, with no generated id. The wrapper is
decided by whether the prop is present, not by its value. That is deliberate:
error={maybeMessage} is the common shape, and if the wrapper came and went
with the message, the input would move in the tree every time validation
changed. React would remount it, losing focus and any uncontrolled value.
Toggling error never remounts the input.
The wiring, done for you:
labelis tied to the control withhtmlFor. The control keeps yourid, or gets a generated one.descriptionanderrorare named inaria-describedby, after any value you passed yourself.- A non-empty
errorsetsaria-invalid, so the field takes its invalid border with no extra prop. Withouterror, your ownaria-invalidstill applies. - The error row has
role="alert". - Screen readers get the plain copy. The animated words are
aria-hidden.
isRequired makes the control required (announced by screen readers and
checked by native form validation) and shows the required marker after the
label: the same red asterisk as PasswordField and Select, aria-hidden so
the name stays the label's text. Off by default. Without a label the control
is still required, with no label to mark. A native required on its own still
only sets the attribute and draws no marker, as before.
<Input label="Email" isRequired />
<Textarea label="Message" isRequired />Limits worth knowing:
- Do not pass
aria-labeltogether withlabel.aria-labelwins, so the visible name is no longer the accessible one (WCAG 2.5.3). - Inside a React Aria
TextFieldthe three props are ignored. TheTextFieldsupplies its own label and messages. - The alert node is inserted together with its text. Whether a given screen reader announces it on insertion is not tested here, so check your own combinations if announcement timing matters.
Motion
The rows move, in pure CSS plus a little plain React (no animation library):
- A row opens on a spring when
descriptionorerrorappears after the field has mounted, and closes the same way when it goes away, still showing its last text while it shuts. A row present on first render is just there: nothing animates on mount. - Changed words rise in from a soft blur, and the old ones lift away from where they stood. Words are matched by position, so changing a word near the front replaces every word after it.
- Numbers roll digit by digit. In copy such as "12 characters", only the digits that changed move. They arrive from below when the number grew and fro
