Components Reference
The catalog. For the pattern behind it — the contract, overrides, the client lifecycle — see UI Components.
Everything below lives in src/components/ui/<folder>/ and is imported from that folder:
import { Button } from "@components/ui/button";
import { Card, CardHeader, CardTitle } from "@components/ui/card";
Every primitive also accepts its element’s native attributes and a class that wins over the base through tailwind-merge.
Forty-four folders, forty-five components: PasswordInput and PasswordStrength share the password/ folder, since one is the meter for the other. Everything else is one folder per primitive.
Tier 1 — foundations
| Primitive | Parts | Variants | JS |
|---|---|---|---|
| Button | Button |
variant primary/secondary/outline/ghost · size sm/md/lg · icon · renders <a> when given href |
— |
| Input | Input |
size sm/md/lg · state default/error/success |
— |
| Label | Label |
— | — |
| Textarea | Textarea |
size sm/md/lg · state default/error/success |
— |
| Badge | Badge |
variant primary/secondary/muted/outline/info/success/warning/error |
— |
| Card | Card CardImage CardHeader CardTitle CardDescription CardAction CardContent CardFooter |
variant default/elevated/outline/interactive · size sm/md/lg |
— |
| Alert | Alert |
variant info/success/warning/error |
— |
| Separator | Separator |
orientation horizontal/vertical |
— |
| Skeleton | Skeleton |
— | — |
| Avatar | Avatar |
size sm/md/lg |
— |
Input, Textarea, Select and AdvancedSelect all compose the same _field module, so the field look and the three validation states have one source of truth.
Tier 2 — disclosure and status
| Primitive | Parts | Variants | JS |
|---|---|---|---|
| Accordion | Accordion AccordionItem AccordionTrigger AccordionContent |
— | — |
| Tabs | Tabs TabsList TabsTrigger TabsContent |
— | yes |
| Tooltip | Tooltip |
side top/bottom/left/right |
— |
| Breadcrumb | Breadcrumb BreadcrumbItem BreadcrumbLink BreadcrumbPage BreadcrumbSeparator |
— | — |
| Pagination | Pagination PaginationItem PaginationLink PaginationEllipsis |
— | — |
| Progress | Progress |
size sm/md/lg |
— |
| Spinner | Spinner |
size sm/md/lg |
— |
Accordion is <details>. Tooltip is CSS-only. PaginationLink reuses the button config rather than redefining one. Tabs is the only one here shipping a script, and it degrades to all-panels-visible without it.
Tier 3 — overlays and controls
| Primitive | Parts | Variants | JS |
|---|---|---|---|
| Dialog | Dialog DialogTrigger DialogClose DialogHeader DialogTitle DialogDescription DialogFooter |
— | yes |
| Sheet | Sheet (+ SheetTrigger / SheetClose, re-exported from Dialog) |
side right/left/top/bottom |
yes |
| Dropdown | Dropdown DropdownTrigger DropdownMenu DropdownItem |
— | yes |
| Select | Select |
size sm/md/lg · state default/error/success |
— |
| Checkbox | Checkbox |
— | — |
| Radio | Radio |
— | — |
| Switch | Switch |
— | — |
| Table | Table TableHeader TableBody TableFooter TableRow TableHead TableCell TableCaption |
— | — |
Dialog and Sheet are native modal <dialog> — a Sheet is a Dialog pinned to an edge via its side variant. They share one delegated controller in _dialog.ts: openers carry data-dialog-open="<id>", closers data-dialog-close, plus backdrop light-dismiss; Escape is native. It binds once and survives view transitions, so it needs no astro:after-swap re-init. The entry and exit animations, the backdrop scrim and the modal scroll-lock live in _overlay.css as real @starting-style and allow-discrete transitions that honour prefers-reduced-motion.
Dropdown is the native Popover API — popover="auto" plus popovertarget, so the menu renders in the top layer and is never clipped, with native light-dismiss, Escape and focus return. The shared _popover.ts controller positions the menu under its trigger, reflows on scroll and resize, adds arrow-key roving (Up/Down/Home/End), and syncs aria-expanded, which is what flips the chevron. The trigger’s for matches the menu’s id.
Checkbox, Radio and Switch are native inputs styled appearance-none with peer and :checked. Switch hides its checkbox sr-only and drives a track and thumb off peer-checked. Zero JS.
Table is static <table> styling in a scroll wrapper. Sorting and data-grid behaviour are out of scope. If you put one in a scroll container, give it tabindex="0", role="region" and a label — an overflow box is not keyboard-focusable on its own in Chrome or Safari, so everything past its right edge is unreachable. The pricing comparison matrix does exactly this, and the ESLint config allows region for that reason.
Advanced form controls
| Primitive | Parts | Variants | JS |
|---|---|---|---|
| Slider | Slider |
size sm/md |
— |
| InputNumber | InputNumber |
— | yes |
| ToggleCount | ToggleCount ToggleCountValue |
— | yes |
| PasswordInput | PasswordInput |
— | yes |
| PasswordStrength | PasswordStrength |
— | yes |
| ComboBox | ComboBox ComboBoxOption |
— | yes |
| AdvancedSelect | AdvancedSelect |
size sm/md/lg · state default/error/success |
yes |
| Searchbox | Searchbox SearchboxItem |
— | yes |
Slider is a native <input type="range"> styled through the range pseudo-elements — a bg-muted track and a bg-primary thumb ringed in background, with Firefox also filling ::-moz-range-progress. Zero JS.
InputNumber is a native <input type="number"> between two steppers (reusing the button config); its script drives stepUp() / stepDown() and disables a stepper at its bound.
ToggleCount is a Monthly/Annual-style pricing toggle built on Switch — every ToggleCountValue for={id} swaps between its min and max text when the toggle flips.
PasswordInput reuses the input field and adds a show/hide button that flips type while keeping aria-pressed and aria-label accurate. PasswordStrength is a four-segment meter driven by the rule-based scorePassword in password/strength.ts, whose one runnable check is password/strength.test.ts.
ComboBox is a role="combobox" input over a filterable role="listbox"; arrow keys move the active option via aria-activedescendant and Enter commits.
AdvancedSelect is a searchable single or multi select backed by a real, visually-hidden native <select> for form submission. It requires JavaScript — the zero-JS alternative is the native Select.
Searchbox is a ⌘K command palette reusing the Dialog shell; its script filters items, adds arrow-key navigation and binds the global shortcut.
The filterable trio share _listbox.ts — filterByText, nextIndex, createActiveDescendant.
Navigation and content
| Primitive | Parts | Variants | JS |
|---|---|---|---|
| Nav | Nav NavItem NavLink |
Nav.orientation horizontal/vertical · NavLink.variant underline/slide/pills/segment |
— |
| MegaMenu | MegaMenu MegaMenuTrigger MegaMenuPanel MegaMenuItem |
MegaMenuPanel.columns 1/2/3 |
yes |
| List | List ListItem |
marker none/disc/decimal · orientation vertical/horizontal · ListItem.icon |
— |
| Marquee | Marquee |
direction left/right/up/down · speed slow/normal/fast |
— |
| ThemeToggle | ThemeToggle |
reuses the button config |
yes |
NavLink models active state with aria-current, zero JS — both spellings come from navCurrent in @js/nav, and the styling matches the attribute rather than its value.
MegaMenu is a Dropdown with a wide multi-column panel — the same move as Sheet reusing Dialog. It is placed by the same _popover.ts controller, and the trigger reuses the navLink config so it sits correctly between links in a Nav. The panel keeps natural Tab order rather than roving, because it is a grid of links and not a role="menu". Click to open only — hover triggers are hostile to touch and keyboard.
Marquee is a seamless pure-CSS infinite scroll. The slotted content renders repeat times as identical side-by-side tracks running one keyframe, each shifting a full copy width plus gap, so the loop has no seam. Copies after the first are aria-hidden — they are visual duplicates, and a screen reader must not read the logo wall three times. Its keyframes and --animate-marquee* tokens live in tailwind-theme.css, and --marquee-gap there must match the gap utility the component uses.
ThemeToggle’s sun/moon flip is CSS only, via the dark: variant, so it is correct pre-paint with no flash; only the click ships JavaScript. It is the one primitive that is not purely additive: it pairs with the inline pre-paint script in BaseHead, which reads localStorage("colorTheme"). That script must stay inline.
Motion primitives
| Primitive | Animates | Driven by | JS |
|---|---|---|---|
| Reveal | one box | native scroll timeline | — |
| CurtainReveal | one box | native scroll timeline | — |
| ImageReveal | a photograph | native scroll timeline | — |
| StaggerReveal | a group of siblings | IntersectionObserver + CSS | yes |
| SplitReveal | a string | IntersectionObserver + CSS | yes |
| CountUp | a number | IntersectionObserver + rAF | yes |
| RollText | a string | :hover / :focus-visible |
— |
Reveal takes animation (eleven entrances) and range (entry cover contain gradual moderate brisk rapid); ImageReveal takes range with the first five of those.
All of them follow siteSettings.useAnimations with a per-call animate override, and all degrade to static under prefers-reduced-motion. Motion & Animation covers the two switches and the two gotchas — the StaggerReveal hidden-child trap and why ImageReveal is wrong for UI mocks.
The shared internal modules
Not primitives — a leading underscore says so, which is why they never appear in the inventory above.
_client.ts (the onReady re-init contract) · _dialog.ts · _popover.ts · _listbox.ts · _field.ts · _overlay.css · _Chevron.astro · password/strength.ts
Cards
Content-aware compositions in src/components/Cards/, built from the ui/card parts. Each takes a whole collection entry rather than its fields.
| Card | Takes | Sidecar config |
|---|---|---|
BlogCard |
CollectionEntry<"blog"> |
_blogCard.ts |
CaseStudyCard |
CollectionEntry<"caseStudies"> |
— (no variants) |
IntegrationCard |
CollectionEntry<"integrations"> |
_integrationCard.ts |
_listingCard.ts holds the shape the two listing cards share.
Seeing them all
/examples/ui in dev renders every primitive in every variant across eight panels, plus the full icon registry and the motion catalog. About twenty of the 44 are used by no page in the theme — that is inventory, and this is how you shop it.