UI Components
src/components/ui/ holds 44 primitives built on tailwind-variants. They are source files in your project, not a package — you own them from the first commit, and customising one means editing it, not shadowing it.
The library is informed by the proven tailwind-variants shape (a folder, a tv() config, an index.ts re-export) but it is the theme’s own. It is not a vendored kit, not a CLI-generated set, and not Preline — Preline is a markup and states reference only, and preline.js is never loaded.
src/components/ui/README.md is the source of truth in the repository. This page explains what it means in practice; the full inventory with every variant axis is in the Components Reference.
The contract — five rules
Every primitive follows all five. If you add one, it must too.
1. One folder per primitive. src/components/ui/<name>/<Name>.astro plus an index.ts. A compound primitive keeps each part as its own file in the same folder — card/ holds eight files, table/ holds eight, dialog/ holds seven.
2. Typed props are native plus variants.
type Props = HTMLAttributes<"input"> & VariantProps<typeof input>;
Add & { … } for anything extra (href, src, for). Because the native attributes are in the type, everything you would expect on the element — required, autocomplete, aria-*, data-* — works without the primitive enumerating it.
3. Export the tv() config, named after the component, so consumers can compose or extend it. This is why ESLint’s astro/no-exports-from-components rule is switched off for ui/** and svg/** specifically — the export is a static config importable at build time, not a runtime escape hatch.
4. Tokens only, never raw colours. Every class resolves to a semantic token: bg-primary, text-foreground, border-input, ring-outline, bg-error. This is what makes dark mode and re-theming free. See Colors & Theming.
5. Merge consumer overrides. Destructure class: className, spread ...rest, pass the class through the config so tailwind-merge lets the last conflicting utility win, and tag the root with data-slot="<name>" as a styling hook.
The whole shape, end to end:
---
import type { HTMLAttributes } from "astro/types";
import { tv, type VariantProps } from "tailwind-variants";
type Props = HTMLAttributes<"input"> & VariantProps<typeof field>;
export const field = tv({
base: ["…layout + typography, token utilities only…"],
variants: { size: { sm: "…", md: "…", lg: "…" } },
defaultVariants: { size: "md" },
});
const { size, class: className, ...rest } = Astro.props;
---
<input class={field({ size, class: className })} data-slot="field" {...rest} />
// index.ts
import Field, { field } from "./Field.astro";
const FieldVariants = { field };
export { Field, FieldVariants };
export default Field;
Using them
---
import { Button } from "@components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@components/ui/card";
---
<Card variant="elevated" size="lg">
<CardHeader>
<CardTitle>Automate the busywork</CardTitle>
</CardHeader>
<CardContent>…</CardContent>
</Card>
<Button variant="outline" size="lg" href="/pricing/">See pricing</Button>
Button renders an <a> when you pass href and a <button> otherwise, so a link that looks like a button is still a link.
Overriding without forking
Three levels, cheapest first.
Pass a class. The merge rule means your class wins over a conflicting base utility:
<Button class="w-full rounded-full">Get started</Button>
Compose the exported config when you need a variant the primitive does not have:
import { button } from "@components/ui/button/Button.astro";
import { tv } from "tailwind-variants";
export const ghostPill = tv({ extend: button, base: "rounded-full px-8" });
Edit the file when the change is permanent and site-wide. It is your source. There is no upstream to fight.
One trap worth knowing, because it cost real time in this codebase: a consumer class outranks a state variant. tv() merges the caller’s class last, so if a wrapper’s shared class string carries a border-* utility, it silently beats the border-error that state="error" would set — the prop is passed and inert. The contact form’s _surface.ts therefore carries no border colour at all, and ContactField picks the border per state inside the same consumer string. Check for it by rendering the error state and grepping the output for border-error, not by reading the class list.
Interactivity: native first
The policy is zero-JS and native HTML wherever the platform will do it — <details>, <dialog>, the Popover API, :has() and peer. A small bundled ES module is a last resort, never a global plugin.
That policy produced these choices:
- Accordion is
<details>/<summary>. No script. - Dialog and Sheet are native modal
<dialog>. A Sheet is a Dialog pinned to an edge by asidevariant, reusing Dialog’s trigger, close and content parts. They share one delegated controller,_dialog.ts— openers carrydata-dialog-open="<id>", closersdata-dialog-close, plus backdrop light-dismiss; Escape is native. It binds once and survives view transitions with no re-init. - Dropdown and MegaMenu are the native Popover API (
popover="auto"+popovertarget), so the panel renders in the top layer and is never clipped by an ancestor’s overflow. Native light-dismiss, Escape and focus return come free. The shared_popover.tscontroller positions the panel, reflows on scroll and resize, adds arrow-key roving where appropriate, and syncsaria-expanded— which is what flips the chevron. - Checkbox, Radio, Switch are native inputs styled
appearance-nonewithpeerand:checked. Switch hides its checkboxsr-onlyand drives a track and thumb frompeer-checked. Zero JS. - Select is a native
<select>sharing the field look. For a searchable dropdown, reach for ComboBox or AdvancedSelect — and note that AdvancedSelect requires JavaScript, which is exactly why the native Select still exists beside it. - Tooltip is CSS-only.
- Reveal is driven by the native scroll timeline — zero JS. See Motion & Animation.
The ones that do ship a script keep it small, re-initialise on astro:after-swap, and state their ceiling with a ponytail: comment: Tabs, InputNumber, PasswordInput, ComboBox, AdvancedSelect, Searchbox, CountUp, SplitReveal, StaggerReveal, GrainyPanel and ThemeToggle.
The three shared owners
Three modules own a browser concern site-wide so that no individual component binds it itself. This is the part of the library most worth internalising before you write a scripted component.
_client.ts — onReady(selector, wire) is the load-plus-astro:after-swap re-init contract every scripted primitive shares. It also owns a once-per-element guard backed by a WeakSet, so wire runs at most once per element ever. Do not re-add a per-component data-<name>-wired flag; eleven components each invented one and ten more ran without, and consolidating that is what this module is.
_scroll.ts — onScrollFrame(el, update) is one rAF-coalesced scroll and resize pass for the whole site. update runs once on registration and stops when the element leaves the document; there is no teardown to call. Never add a window scroll listener inside a wire — that shape leaves a stale listener per navigation until the next scroll. Continuous animation that must run between scroll events (an inertial lerp) still needs its own rAF loop, and Sections/Home/Why.astro is the one such holdout, which says so in the file.
_hotkey.ts — onCommandK(el, open) is the single owner of ⌘K / Ctrl-K. The last still-connected registration wins, and with no live target the chord is left to the browser. Binding keydown directly means two components can claim it and both fire.
The other shared modules are simpler: _field.ts (the base look and validation states every text field composes), _dialog.ts, _popover.ts, _listbox.ts (filterByText / nextIndex / createActiveDescendant for the three filterable-listbox components), _overlay.css (real @starting-style and allow-discrete transitions for Dialog and Sheet, plus the modal scroll lock), and _Chevron.astro (the one disclosure glyph — rotation selectors stay at call sites).
A leading underscore means “internal shared module, not a primitive”. They have no folder and no index.ts.
The house CTA recipe
CtaButton and RollText are worth calling out because they encode a brand micro-interaction rather than a generic control. RollText splits its text into characters at build time — which is why the text is a prop and not a slot — and rolls them on the nearest Tailwind group hover or focus-visible, purely in CSS with a motion-reduce:transition-none guard. CtaButton composes Button (with group), RollText and an optional trailing icon that nudges right on hover; its tone maps to a real Button variant so the treatment cannot drift from the button system.
It is a micro-interaction, so it is deliberately not gated on siteSettings.useAnimations.
ThemeToggle is the one non-additive primitive
Every other primitive is a file you drop in. ThemeToggle pairs with a one-time edit to BaseHead’s inline pre-paint script, which reads the localStorage("colorTheme") key the toggle writes. The sun/moon flip itself is CSS-only via the dark: variant, so it is correct pre-paint with no flash; only the click ships JavaScript. If you remove the toggle, the pre-paint script still works — it simply never finds a saved value and light stays the default.
The catalog is the check
/examples/ui renders every primitive in every variant, plus the motion catalog and every icon. It is dev-only: getStaticPaths emits no paths in a production build.
After adding or changing a primitive, run pnpm lint and pnpm build, then open the catalog and look at it in light and dark. A missing token shows up instantly as an un-themed element, which is the failure this library is most prone to and the one a type checker cannot catch.
Adding a primitive
Follow the five rules, put it in its own folder with an index.ts, add it to the relevant catalog section under Sections/UiCatalog/, and check it in both themes. If it needs a script, use onReady from _client.ts rather than binding load events yourself, use onScrollFrame rather than a scroll listener, and use onCommandK rather than a keydown handler. If it has non-trivial pure logic, leave one runnable check beside it named *.selfcheck.ts or *.test.ts — pnpm test finds it by filename with nothing to wire, which is how password/strength.ts and count-up/format.ts are covered.