Skip to content
AstroCraft Docs
On this theme

UI Components

Finly ships its own UI primitive library — 61 primitives in src/components/ui/, built on tailwind-variants. It is not a vendored kit, not a CLI you run, not a package you update. Every primitive is a file you own from the first commit.

src/components/ui/README.md is the source of truth for the pattern, and every primitive follows it.

The contract — 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 .astro file in the same folder — Card has seven.

2. Typed props are native plus variants.

type Props = HTMLAttributes<"button"> & VariantProps<typeof button> & { href?: string };

Every native attribute for the element the primitive renders is accepted, on top of its own variants.

3. Export the tv() config, named after the component, so consumers can compose or extend it.

4. Tokens only, never raw colors. Every class resolves to a semantic token — bg-primary, text-foreground, border-input, ring-outline. This is what keeps dark mode and re-theming free.

5. Merge consumer overrides. Destructure class: className, spread ...rest, pass class: className 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 shape

---
// src/components/ui/button/Button.astro
import type { HTMLAttributes } from "astro/types";
import { tv, type VariantProps } from "tailwind-variants";

type Props = HTMLAttributes<"button"> & VariantProps<typeof button> & { href?: string };

export const button = tv({
  base: [
    "inline-flex items-center justify-center gap-2 rounded-md font-medium",
    "transition-all outline-none focus-visible:ring-3 focus-visible:ring-outline/50",
    "disabled:pointer-events-none disabled:opacity-50",
    "[&_svg]:pointer-events-none [&_svg]:shrink-0",
  ],
  variants: {
    variant: {
      primary: "bg-primary text-primary-foreground hover:bg-primary/90",
      secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
      outline: "border-border text-foreground hover:bg-muted border bg-transparent",
      ghost: "text-foreground hover:bg-muted",
    },
    size: { sm: "h-9 px-3 text-sm", md: "h-11 px-4 text-base", lg: "h-12 px-6 text-lg" },
    icon: { true: "px-0" },
  },
  compoundVariants: [
    { icon: true, size: "sm", class: "w-9" },
    { icon: true, size: "md", class: "w-11" },
    { icon: true, size: "lg", class: "w-12" },
  ],
  defaultVariants: { variant: "primary", size: "md" },
});

const { variant, size, icon, href, class: className, ...rest } = Astro.props;
const Tag = href ? "a" : "button";
---

<Tag href={href} class={button({ variant, size, icon, class: className })} data-slot="button" {...rest}>
  <slot />
</Tag>

The index.ts beside it is three lines:

import Button, { button } from "./Button.astro";
const ButtonVariants = { button };
export { Button, ButtonVariants };
export default Button;

Notice Tag = href ? "a" : "button" — the primitive renders the right element for what it is rather than making the caller choose, which is the pattern several primitives use to keep semantics correct by construction.

Using and overriding

<Button>Book a demo</Button>
<Button variant="outline" size="lg" href="/pricing/">See pricing</Button>
<Button icon size="sm" aria-label="Close"><Icon name="x-01" /></Button>

Overrides go through class, and tailwind-merge resolves the conflict in your favour:

<Button class="rounded-full">Pill</Button>       <!-- beats the base rounded-md -->

To extend rather than override, import the config and compose:

import { button } from "@components/ui/button";

const ctaButton = tv({ extend: button, base: "shadow-lg" });

And to reach inside from a parent, use the data-slot hook:

[data-slot="card"]:hover [data-slot="button"] { /* … */ }

Interactivity policy: native first

Zero JavaScript and native HTML wherever the platform will do it — <details>, <dialog>, the Popover API, @starting-style, :has(), peer. A tiny bundled <script> only when native will not do. Never a global plugin.

Of the 61 primitives, nineteen components ship any script at all. The rest are markup and CSS.

What that buys, concretely:

  • Dialog and Sheet are native modal <dialog> — a Sheet is a Dialog pinned to an edge via a side variant. Escape is native, light-dismiss is a backdrop click, and the entry/exit animations are real @starting-style and allow-discrete transitions in _overlay.css.
  • Dropdown and MegaMenu are the native Popover API — popover="auto" plus popovertarget — so the panel renders in the top layer and is never clipped, with native light-dismiss, Escape and focus return.
  • Accordion is <details>. Tooltip is CSS-only. Checkbox, Radio and Switch are native inputs styled appearance-none with peer and :checked. Slider is <input type="range"> styled through the range pseudo-elements.
  • Reveal in its default mode drives its animation off a native scroll timeline, which is zero JavaScript for the whole scroll-reveal system.

Where a script is genuinely required, the primitive says why in a ponytail: comment naming its ceiling — AdvancedSelect requires JavaScript, and its own header points at the native Select as the zero-JS alternative.

The client lifecycle

Every scripted primitive wires itself through one shared helper, _client.ts:

export function onReady(
  selector: string,
  wire: (el: HTMLElement, signal: AbortSignal) => void | (() => void),
): void

onReady runs wire for every matching element on load and again after each view-transition swap (astro:after-swap). The re-init contract lives in one place so it cannot be forgotten — which matters, because <ClientRouter /> is mounted by default and a primitive that binds only on load stops working after the first client-side navigation.

The AbortSignal is what makes re-wiring idempotent. It is aborted before the next pass, so anything registered with { signal } is released automatically. Without it, an element that survives a swap under transition:persist would collect a second copy of its listeners each time — a persisted theme toggle would toggle twice per click. For anything with no signal option of its own — a ResizeObserver, a timer — return a cleanup function instead; it runs at the same moment the signal aborts.

The signal is opt-in, and the primitives written before the contract still bind without it. Nothing in the theme persists a subtree today, so that is latent rather than live, and the file says so.

Shared internals

Leading-underscore modules in src/components/ui/ are shared internals, not primitives:

  • _client.ts — the onReady contract above.
  • _dialog.ts — one delegated controller for Dialog and Sheet. Openers carry data-dialog-open="<id>", closers data-dialog-close. It binds once and survives view transitions with no re-init at all.
  • _popover.ts — placement for Dropdown and MegaMenu: positions the panel under its trigger, reflows on scroll and resize, adds arrow-key roving, and syncs aria-expanded.
  • _field.ts — the shared input look, so Input, Textarea, Select and AdvancedSelect cannot drift.
  • _listbox.tsfilterByText, nextIndex and createActiveDescendant, shared by ComboBox, AdvancedSelect and Searchbox.
  • _motion.tsanimationsOn, the one place the animate prop / useAnimations / default precedence is spelled out.
  • _reveal.ts — the play-on-first-sight controller shared by Reveal, SplitText and SentenceReveal.
  • _overlay.css and _prose.css — the two global stylesheets, imported from the components that need them.

If you find yourself writing the same eight lines in a second primitive, this folder is where the ninth copy does not go.

Extraction, not invention

Several of the newer primitives exist because the same shape turned up in five different places. NotchedCard — the design’s signature card with a corner cut away around a 56-pixel arrow button — was a private <style> block inside one home-page card until it turned out to be drawing five nodes. Chip was three copies of the same tv() config, and they had already drifted: two differed only in class order because one had been hand-edited past the class sorter, and the rules beneath them sat at different margins while all three files’ comments asserted the rows must behave identically.

That is the habit worth keeping. Before adding a primitive, check whether one already covers it — about twenty of the 61 are used by no page in the theme, which is inventory for the site you build next.

The check

/examples/ui/ renders every primitive in every variant, in nine panels, and it is the fastest way to shop the library. After adding or changing a primitive, run pnpm lint and pnpm build, then open the catalog and eyeball it in light and dark — a missing token shows up instantly as an un-themed element.

If the primitive contains real logic, split the logic into a plain .ts beside it and leave a runnable check: password/strength.ts has strength.test.ts, count-up/format.ts has format.test.ts, and nav-highlight/geometry.ts has geometry.test.ts. The runner discovers them; there is nothing to register.

The catalog is dev-only — its getStaticPaths returns [] in production — so it costs nothing to keep while you work. See Components Reference for what is in the library.

NEXT STEPIcons