Skip to content
AstroCraft Docs
On this theme

UI Components

8-BitQuest ships its own low-level component library at src/components/ui/ — 39 primitives built on tailwind-variants, consuming the token architecture directly, so every one is themeable and dark-mode correct for free. It is not a vendored kit: there is no CLI, no preline package, no runtime UI dependency beyond tailwind-variants and tailwind-merge. The primitives are the main consumer of the token layer, and they are the fastest way to build a page without reinventing a button.

For the complete inventory — every primitive with its variant axes and defaults — see Components Reference. This page is how they work.

The five-rule contract

Every primitive follows the same contract, codified in src/components/ui/README.md. Knowing it means you can read any primitive and predict the rest:

  1. One folder per primitiveui/<name>/<Name>.astro plus an index.ts barrel.
  2. Typed props are native plus variantstype Props = HTMLAttributes<tag> & VariantProps<typeof config>, so a primitive accepts every attribute the underlying element does, plus its own variant props.
  3. The tv() config is exported, named after the component, so consumers can compose it.
  4. Tokens only, never raw colours — a primitive uses bg-primary and border-border, never bg-blue-500.
  5. Consumer overrides merge — the component destructures class: className, spreads ...rest, passes your class through the config, and tags its root with data-slot="<name>".

Button shows the shape end to end: it exports export const button = tv({…}), renders <a> when given an href and <button> otherwise, carries data-slot="button", and merges your class over its own.

Using a primitive

Import it and use it. Every folder’s barrel re-exports the component, its variants, and its tv() config:

---
import { Button } from "@components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@components/ui/card";
---

<Button variant="secondary" size="lg">Start Quest</Button>

<Card variant="elevated">
  <CardHeader><CardTitle>Module 01</CardTitle></CardHeader>
  <CardContent>…</CardContent>
</Card>

Variant props are typed, so an illegal variant or size fails astro check and every option autocompletes. A compound primitive like Card, Dialog or Table exports its parts from the same barrel.

Overriding a primitive

You almost never edit a primitive to change how it looks in one place. Pass a class. Because tv() runs tailwind-merge internally, your utility wins the conflict:

<Button class="w-full">Wide button</Button>

The w-full overrides the primitive’s own width without your touching its file. This is the reason the primitives are “done” — restyling is a prop, not a fork. For deeper composition, import the exported config (buttonVariants, or the named button) and build on it; that is how PaginationLink, DialogTrigger, DropdownTrigger and the InputNumber steppers reuse the button recipe instead of cloning it, so a pager link and a button can never drift apart.

The interactivity policy

The library holds a strict line on JavaScript: native HTML first, a small bundled script only when native cannot do it, never a component framework. Most of the 39 primitives ship zero JavaScript:

  • Accordion is native <details>/<summary> — exclusive open and keyboard support are free.
  • Tooltip is CSS-only, revealed on hover and focus-within.
  • Dialog and Sheet are the native modal <dialog> — focus trap, page inertness and Escape come free.
  • Dropdown and MegaMenu use the native Popover API — top-layer, so they are never clipped, with native light-dismiss.
  • Checkbox, Radio, Switch, Select and Slider are native inputs styled with appearance-none.
  • Reveal and Marquee are pure CSS — a native scroll timeline and a keyframe loop.

Thirteen primitives do ship a small script, and only where the platform has no answer: Tabs (HTML has no tab element), the popover controllers, the filterable listboxes (ComboBox, AdvancedSelect, Searchbox), the steppers, the password fields, ToggleCount, and ThemeToggle. Every one degrades gracefully without JavaScript.

The shared modules

Seven leading-underscore files in src/components/ui/ are internal shared modules, not primitives — the library’s single sources of truth for cross-cutting look and behaviour. Extracting them rather than re-copying is the same reuse discipline the contract enforces:

  • _field.ts — the form-field contract (fieldBase plus a fieldState for default/error/success). Input, Textarea, Select and AdvancedSelect all compose it, so validation styling has one home.
  • _dialog.ts — one delegated controller for every native <dialog>. It binds once on the document and survives view transitions, shared by Dialog, Sheet and (through the Dialog shell) Searchbox.
  • _popover.ts — the anchored-popover controller shared by Dropdown and MegaMenu: placement, viewport clamping, aria-expanded sync, and arrow-key roving for menus.
  • _listbox.ts — the filter-and-rove kernel shared by ComboBox, Searchbox and AdvancedSelect.
  • _overlay.css — the dialog/sheet enter-exit animations, backdrop scrim and modal scroll-lock.
  • _Chevron.astro — the one disclosure/select chevron glyph, deduped from its six call sites.
  • _client.tsonReady(selector, wire), which runs a primitive’s wiring on load and after each astro:after-swap. This is the re-init contract every scripted primitive shares, so the view-transition invariant can never be forgotten.

The takeaway: an interactive primitive is a thin .astro file plus a shared module, and a new one that needs the same behaviour reuses the module rather than re-copying it.

The one lint concession

Rule 3 — exporting the tv() config from an .astro frontmatter — collides with the recommended astro/no-exports-from-components ESLint rule. A scoped override disables that one rule for the two contract-following trees (src/components/ui/**/*.astro and src/components/svg/**/*.astro, the icon primitives), documented in place in eslint.config.mjs. Everything else in those files lints normally.

Seeing them all

pnpm dev, then /examples/ui. Every primitive in every variant, with a theme toggle in the header, plus the icon registries and the motion catalog. It is development-only and ships no pages in production — and it is your regression guard: a primitive that has lost a token shows up instantly as an un-themed element. Delete src/pages/examples/ and src/components/Sections/UiCatalog/ before launch once you have finished choosing components.

NEXT STEPIcons