UI Components
src/components/ui/ holds 46 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.
Because they are yours, they follow a contract rather than an API. Learn the contract once and every primitive behaves the same way.
The five rules
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/Card.astro, card/CardHeader.astro, card/CardTitle.astro.
2. Props are native attributes plus variants.
type Props = HTMLAttributes<"button"> & VariantProps<typeof button> & { href?: string };
So every native attribute — disabled, id, aria-*, data-* — type-checks and lands on the element. Variant props are destructured out so they never leak into the DOM.
3. The tv() config is exported, named after the component, so you can compose or extend it elsewhere.
4. Tokens only, never raw colours. Every class resolves to a semantic token. This is what makes dark mode and re-theming free.
5. Consumer overrides merge. Destructure class: className, pass it through the config, spread the rest, and tag the root with data-slot.
Button.astro is the whole contract in one file:
---
export const button = tv({
base: [
"inline-flex items-center justify-center rounded-md font-medium",
"transition-all outline-none focus-visible:ring-3",
"disabled:pointer-events-none disabled:opacity-50",
],
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
ghost: "hover:bg-muted",
},
size: { sm: "h-9 px-3 text-sm", md: "h-11 px-4 text-base" },
},
defaultVariants: { variant: "primary", size: "md" },
});
type Props = HTMLAttributes<"button"> & VariantProps<typeof button> & { href?: string };
const { variant, size, href, class: className, ...rest } = Astro.props;
const Tag = href ? "a" : "button";
---
<Tag href={href} class={button({ variant, size, class: className })} data-slot="button" {...rest}>
<slot />
</Tag>
Using them
Both import styles work:
---
import Button from "@components/ui/button";
import { Card, CardImage, CardHeader, CardTitle, CardDescription } from "@components/ui/card";
---
<Card variant="interactive" class="max-w-sm">
<CardImage src="/photo.jpg" alt="" />
<CardHeader>
<CardTitle>Shipping a token-driven UI</CardTitle>
<CardDescription>How the token bridge keeps dark mode free.</CardDescription>
</CardHeader>
</Card>
To override styling, pass a class. Because tv() runs tailwind-merge internally, your conflicting utility wins:
<Button class="rounded-full px-10">Send</Button>
That beats the base rounded-md and the size’s px-4. There is no cn() or clsx helper in this project, and adding one is not the fix — tv() already does the merging.
To reach a primitive’s classes from your own component, import the exported config:
import { NavVariants } from "@components/ui/nav";
const cls = NavVariants.navLink({ variant: "glitch", class: "text-foreground" });
The footer does exactly this to give an email address the site’s link vocabulary without pretending an address is navigation.
The data-slot convention
Every primitive root carries data-slot="<kebab-name>". It has three uses in the shipped code, and all three are worth stealing:
- Composition selectors —
CardHeaderuseshas-[[data-slot=card-action]]:grid-cols-[1fr_auto]to reflow when an action is present. - Script selection — every interactive primitive wires with
onReady('[data-slot="tabs"]', initTabs). - Global CSS hooks —
_overlay.cssrestoresmargin: autoon[data-slot="dialog"].
There is a fourth, subtler use. The motion primitives emit data-slot={on ? "magnetic" : undefined}, so when the master animation switch is off the hook itself is absent and the script never binds. The switch removes the work, not just the effect.
Interactivity policy
Native HTML first. <details> for the accordion, <dialog> for the modal and the sheet, the Popover API for dropdowns and mega menus, :has() and peer for state that CSS can express.
A bundled <script> only when native will not do — and always through the shared onReady contract, never a global plugin.
The shared modules
Files with a leading underscore in src/components/ui/ are internal, shared by several primitives.
_client.ts is the most important one. It provides four functions:
export function onReady(selector: string, wire: (el: HTMLElement) => void): void {
const wired = new WeakSet<HTMLElement>();
const init = (): void =>
document.querySelectorAll<HTMLElement>(selector).forEach((el) => {
if (wired.has(el)) return;
wired.add(el);
wire(el);
});
init();
document.addEventListener("astro:after-swap", init);
}
Use this for any client script you add. It wires on load and again after every view-transition swap, and it wires each element at most once. That guarantee matters more than it looks: a transition:persisted node is moved into the new document rather than recreated, so a naive re-query hands it a second set of listeners on every navigation. Four primitives each hand-rolled a data-*-bound attribute against exactly that before this existed.
The other three: prefersReducedMotion() reads the media query live rather than caching a boolean; whenVisible() resolves the first time the document becomes visible, guarding one-shot entrances started in a background tab; whenInView(el) is the shared intersection trigger, with its observer disconnected on astro:before-swap.
_field.ts holds the form-field base classes and the three states (default, error, success), composed by Input, Textarea, Select and AdvancedSelect, and through Input by the password, combobox and number primitives. Restyling form fields means editing this one file.
_dialog.ts is a single delegated click controller for every <dialog> on the page: openers, closers and backdrop light-dismiss. It binds once at module scope, so it survives view transitions with no re-init. Escape is native.
_overlay.css carries dialog and sheet enter/exit transitions using @starting-style and allow-discrete — real entry and exit animation with no JavaScript — plus the scroll lock. It also restores margin: auto on [data-slot="dialog"], because Tailwind’s preflight resets margin: 0 on every element and clobbers the browser default that centres a modal.
_listbox.ts is the filterable-listbox kernel behind ComboBox, Searchbox and AdvancedSelect: text filtering, wrap-around roving index math, and a WAI-ARIA active-descendant helper. It has its own test.
_popover.ts places anchored top-layer panels for Dropdown and MegaMenu — positioning, viewport clamping, flip-above, arrow-key roving, and close-on-activate. A new anchored primitive opts in by adding its data-slot to one set.
_motion.ts is the single entrance-delay scale, exported as a map of whole static class strings. That shape is deliberate: Tailwind scans source text, so a computed animate-delay-${n} emits no CSS at all.
The four cards
src/components/Cards/ holds content-aware compositions, usually built on ui/card:
ProjectCard— takes aCollectionEntry<"work">plus anindexfor thePROJECT / NNlabel. Layout variants for the home grid and the work index.ArticleCard— takes aCollectionEntry<"blog">, with aheadingLevelprop and a featured layout.RelatedArticleCard— the compact variant under a post.ProcessStepCard— plain props (index,title,body,photo) rather than a collection entry.
Two contract rules govern them. Cards own their insides only — no margin, no width, no arrival animation, because whatever places the card owns those. And ui/card is the usual base, not a requirement: ProjectCard is a bare frame over a text stack closed by a hairline rule, so it composes directly instead of overriding away every base class the primitive exists to supply.
Shared class recipes live in Cards/_cards.ts — the hover arrow and the title treatment. Note that the arrow is deliberately not gated on the animation master switch: it is a micro-interaction, not decoration, and it stands down only for reduced motion.
Highlights worth knowing about
DistortImage wraps any image in a WebGL liquid-pixel ripple that sweeps once on arrival and then reacts to the pointer. It is a wrapper, not an image component — you supply your own <Image> or <Picture>, so widths, formats and art direction stay at the call site. It has a real budget: one WebGL context per instance, and browsers drop the oldest past roughly sixteen. The home page mounts fifteen. Before adding another frame there, read the ceiling note in the component.
Reveal is the scroll-entrance wrapper — fourteen animations, three trigger modes, a shared delay scale. Covered in full under Motion.
EllipseLink is the theme’s signature pill: a drawn ellipse outline, a rolling label, and a magnetic pull. It is deliberately not built on Button.
Marquee, CircularText, TextReveal, Magnetic and PageTransition round out the motion set.
The form set is unusually complete for a portfolio theme: Input, Textarea, Select, AdvancedSelect, ComboBox, Searchbox, Checkbox, Radio, Switch, Slider, InputNumber, PasswordInput with a strength meter, Label, Progress. Most of it is unused by the shipped pages and is there for what you build next.
Adding a primitive
Copy the shape of an existing one. Create src/components/ui/<name>/<Name>.astro with an exported tv() config and data-slot, add <name>/index.ts re-exporting the component and its config, use tokens only, and merge the consumer class.
If it needs script, use onReady. If it needs a shared behaviour that already exists, import the underscore module rather than reimplementing it.
Then check it: run pnpm dev, open /examples/ui, and look at it in light and dark. A missing token shows up instantly as an un-themed element. Add it to the relevant catalog file so it stays visible to the next person.
The full inventory
Every primitive with its variant axes is listed on the Components Reference page.