Components Reference
The catalog. For the pattern behind it — the contract, overrides, the client lifecycle — see UI Components.
The live version of this page is /examples/ui in dev. It renders every primitive in every variant from the real source, so it cannot go stale. Check it before you build something.
Foundations
| Primitive | Parts | Variants |
|---|---|---|
| Button | Button |
variant primary·secondary·outline·ghost · size sm·md·lg · icon |
| Badge | Badge |
variant primary·secondary·muted·outline·info·success·warning·error |
| Alert | Alert |
variant info·success·warning·error |
| Card | Card CardImage CardHeader CardTitle CardDescription CardAction CardContent CardFooter |
variant default·elevated·outline·interactive · size sm·md·lg |
| Avatar | Avatar |
size sm·md·lg |
| Separator | Separator |
orientation · vertical |
| Skeleton | Skeleton |
— |
| Spinner | Spinner |
size sm·md·lg |
| Logo | Logo |
size sm·md·lg |
Skeleton uses Tailwind’s own animate-pulse and Spinner its animate-spin — the theme’s motion catalog deliberately does not redefine those.
Logo is the Develi lockup as one inline SVG so each half takes its colour from the theme: the wordmark is fill-current, the mark is fill-primary. Inline rather than an <img> precisely so currentColor works. Decorative by default — the wrapping link owns the accessible name; pass title when it stands alone.
Form controls
| Primitive | Parts | Variants |
|---|---|---|
| Input | Input |
size sm·md·lg · state |
| Textarea | Textarea |
size · state |
| Label | Label |
size |
| Select | Select |
size sm·md·lg |
| Checkbox | Checkbox |
— |
| Radio | Radio |
— |
| Switch | Switch |
— |
| Slider | Slider |
size sm·md |
Input, Textarea, Select, ComboBox and AdvancedSelect all compose the shared _field.ts config (fieldBase + fieldState), so the field look and its validation states are defined once.
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. All zero-JS.
Slider is a native <input type="range"> styled through its range pseudo-elements — a bg-muted track, a bg-primary thumb ringed in background, and Firefox’s ::-moz-range-progress filled. Zero-JS.
Advanced form controls
| Primitive | Parts | JS |
|---|---|---|
| InputNumber | InputNumber |
yes |
| ToggleCount | ToggleCount ToggleCountValue |
— |
| PasswordInput | PasswordInput |
yes |
| PasswordStrength | PasswordStrength |
yes |
| ComboBox | ComboBox ComboBoxOption |
yes |
| AdvancedSelect | AdvancedSelect |
yes |
| Searchbox | Searchbox SearchboxItem |
yes |
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, checked by strength.test.ts.
ComboBox is a role="combobox" input over a filterable role="listbox" — arrow keys move the active option via aria-activedescendant, Enter commits.
AdvancedSelect is a searchable single/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 global shortcut handler is latched at module scope and bound to document once — the one deliberate exception to the teardown contract, because it must outlive any one palette.
The filterable trio share _listbox.ts (filterByText, nextIndex, createActiveDescendant), checked by _listbox.test.ts.
Disclosure & status
| Primitive | Parts | JS |
|---|---|---|
| Accordion | Accordion AccordionItem AccordionTrigger AccordionContent |
— |
| Tabs | Tabs TabsList TabsTrigger TabsContent |
yes |
| Tooltip | Tooltip |
— |
| Progress | Progress |
— |
| Table | Table TableHeader TableBody TableFooter TableRow TableHead TableCell TableCaption |
— |
Accordion is <details> with an exclusive name, so single-open behaviour is native. Its open/close is animated with zero JS through a sidecar _accordion.css using ::details-content and interpolate-size: allow-keywords — three properties that do not express as utilities.
Tabs is the only JS primitive in this group, with roving focus in tabs/roving.ts. It degrades to all panels visible.
Tooltip is CSS-only via group-hover and focus-within, with a side variant (top·bottom·left·right).
Table is static styling in a scroll wrapper. Sorting and data-grid behaviour are deliberately out of scope.
Overlays
| Primitive | Parts | JS |
|---|---|---|
| Dialog | Dialog DialogTrigger DialogClose DialogHeader DialogTitle DialogDescription DialogFooter |
delegated |
| Sheet | Sheet (+ Dialog’s parts, re-exported) |
delegated |
| Dropdown | Dropdown DropdownTrigger DropdownMenu DropdownItem |
delegated |
| MegaMenu | MegaMenu MegaMenuTrigger MegaMenuPanel MegaMenuItem |
delegated |
Dialog and Sheet are native modal <dialog> — a Sheet is a Dialog pinned to an edge via a side variant (right·left·top·bottom), reusing Dialog’s trigger, close and content parts. They share one delegated controller, _dialog.ts: openers carry data-dialog-open="<id>", closers data-dialog-close, plus backdrop light-dismiss. Escape is native.
Dropdown and MegaMenu are the native Popover API (popover="auto" + popovertarget), so the menu renders in the top layer and can never be clipped. The shared _popover.ts positions the menu under its trigger, reflows on scroll and resize, adds arrow-key roving, and syncs aria-expanded. MegaMenu adds a columns variant (1·2·3) setting width and column count, and is click-to-open only — hover triggers are hostile to touch and keyboard.
All four bind once at document level and survive view transitions, which is why they register no per-element wire.
Their entry and exit animations, the backdrop scrim and the modal scroll-lock live in _overlay.css as real @starting-style + allow-discrete transitions.
Navigation
| Primitive | Parts | Variants / JS |
|---|---|---|
| Nav | Nav NavItem NavLink |
orientation horizontal·vertical |
| NavHighlight | NavHighlight |
both axes · JS |
| Breadcrumb | Breadcrumb BreadcrumbItem BreadcrumbLink BreadcrumbPage BreadcrumbSeparator |
— |
| Pagination | Pagination PaginationItem PaginationLink PaginationEllipsis |
— |
| List | List ListItem |
marker none·disc·decimal · orientation |
NavHighlight is Nav’s animated sibling: the links share one rounded pill that glides between them, resizing to each label, and settles on the current page’s link when the pointer leaves. It follows keyboard focus as well as hover. Both orientations are handled by the same code (pillBox in geometry.ts, checked by geometry.test.ts) — there is no orientation branch to get wrong. Movement and timing live in a sidecar driven by four --nav-pill-* custom properties the script writes, so restyling the motion never means reading JS. It degrades to a plain nav with no pill.
Use NavLink variant="bare" inside it — the pill is the hover affordance, so a link that brings its own would fight it.
Pagination ships the markup parts but no opinion about which pages to draw. That is pagination/window.ts, a pure function with its own check. PaginationLink reuses the button config rather than defining one.
Breadcrumb ships unused, and so does its schema counterpart getBreadcrumbSchema. Build the visible nav first, then pass the node — emitting breadcrumb structured data with no on-screen breadcrumb is markup and schema disagreeing.
Site chrome
| Primitive | Parts | Notes |
|---|---|---|
| CtaButton | CtaButton |
composed from Button + RollText + Icon |
| ThemeToggle | ThemeToggle |
icon button, CSS-only icon flip |
| RollText | RollText |
--roll-gap knob |
CtaButton is the house call to action: the label rolls on hover while a trailing arrow-up-right nudges right and rotates its diagonal flat. It exists because the treatment was inlined at three call sites, which is how its arrow drifted to 20px in one and 24px in the others. group is load-bearing — the roll and the arrow key off group-hover and group-focus-visible, so the keyboard path gets the same feedback. label is a prop rather than a slot, because RollText splits the text at build time. Zero-JS.
ThemeToggle is a manual light/dark override. The sun/moon flip is CSS-only via the dark: variant, so it is correct pre-paint with no flash; only the click ships JS. It defaults to icon: true, so size owns both axes — size="md" is a 44px square, the same as any other icon-only Button.
It is the one primitive that is not purely additive: it pairs with the inline pre-paint script in BaseHead. Mount it on any page where light mode should be reachable — a page without it is dark-only.
Content & motion
| Primitive | Parts | Variants / JS |
|---|---|---|
| Reveal | Reveal |
11 animation × 7 range · zero-JS |
| TextReveal | TextReveal |
split words·lines · JS |
| StaggerReveal | StaggerReveal |
stagger tight·normal·loose · JS |
| CountUp | CountUp |
JS |
| Marquee | Marquee |
direction left·right·up·down · speed slow·normal·fast |
| Carousel | Carousel CarouselControls |
JS |
Reveal is the zero-JS default, driven by the native view() scroll timeline. TextReveal, StaggerReveal and CountUp are the play-once exceptions sharing one trigger in _reveal-once.ts. See Motion for all of it.
Marquee is a seamless CSS loop; each track shifts one full copy width plus one gap, so with identical copies laid side by side the next is already in place when one scrolls out. --marquee-gap must match the flex gap or the maths break.
Carousel is a seamless, infinite, draggable track whose prev/next arrows are rendered separately by CarouselControls for={id} — that split is why it exists, because the design puts the arrows in the section header, nowhere near the cards. Pass it the items and nothing else; it owns both the clipping viewport and the rail. The loop arithmetic is pure and checked in carousel/scroll.ts.
Three things about it are worth knowing if you extend it. It is deliberately not a scroll container, because wrapping means teleporting scrollLeft, which cancels an in-flight fling. It writes translate, never transform, so a card can rise in the reveal and be placed by the loop at once. And it clips with overflow-x-clip rather than overflow-hidden — hidden would make it a scroll container again — on the x axis only, so the reveal’s 40px lift is not sheared off the top.
Cards
Seven compositions in src/components/Cards/, built on the ui/card primitives and aware of a data shape:
| Card | Takes |
|---|---|
| PostCard | a blog entry + its author |
| CaseCard | a work entry |
| TeamCard | a person |
| TestimonialCard | a Testimonial |
| ServiceCard | a service |
| SupportCard | a SupportOption |
| NumberedCard | an ordinal + copy |
PostCard has four variants — featured, grid, compact, list — each with its own responsive widths/sizes pair. CaseCard and NumberedCard each carry a raised/recessed surface variant, paired per theme, which is why they keep their own colour pair rather than using the shared .recessed-panel.
Out of scope
Not primitives here, because each needs heavy JS or a third-party library: Datepicker, Time Picker, Color Picker, sortable data tables, Charts, Maps, WYSIWYG, Drag-and-Drop, File Upload, Tree View, Layout Splitter, Custom Scrollbar.
Add one per project if a build needs it. Compositions like a navbar or sidebar are built from these primitives and belong in src/components/Sections/.
The internal modules
Not primitives — shared machinery, marked with a leading underscore:
_client.ts (the wire/cleanup lifecycle) · _field.ts (the shared field look) · _dialog.ts · _popover.ts · _listbox.ts · _reveal-once.ts + _reveal-once.css · _Chevron.astro (the one disclosure chevron glyph) · _overlay.css · password/strength.ts
Plus the pure logic modules that each carry a *.test.ts: pagination/window.ts, carousel/scroll.ts, count-up/count.ts, text-reveal/lines.ts, nav-highlight/geometry.ts, tabs/roving.ts.