Skip to content
AstroCraft Docs
On this theme

UI Components

src/components/ui/ holds 40 primitives, each a folder with a tailwind-variants recipe and an index.ts re-export. It is the theme’s own library — not a vendored kit, not a CLI-generated set, not a package. Preline was a markup and states reference; preline.js is never loaded.

Before anything else, the number that surprises people: three of the forty are used by the design. That is a decision, not neglect, and the second half of this chapter explains it — because if you skip that part you will make the wrong call the first time you build a section.

The contract

Five rules, stated in src/components/ui/README.md, which is the source of truth for the pattern:

  1. One folder per primitiveui/<name>/<Name>.astro + index.ts. A compound primitive keeps each part as its own file in the same folder.
  2. Typed props = native + variantsHTMLAttributes<tag> & VariantProps<typeof config>.
  3. Export the tv() config, named after the component, so consumers can compose or extend it.
  4. Tokens only, never raw colours — every class resolves to a semantic token. This is what keeps dark mode and retheming 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>".

The shape in full:

---
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} />

The interactivity policy

Zero-JS and native HTML first<details>, <dialog>, the Popover API, :has() and peer. A tiny bundled <script> only when native will not do, and never a global plugin.

The library follows this seriously enough to be worth cataloguing:

  • Accordion is <details>. Tooltip is CSS-only.
  • Dialog and Sheet are native modal <dialog> — a Sheet is a Dialog pinned to an edge via a side variant. They share one delegated controller (_dialog.ts) that binds once and survives view transitions; Escape is native.
  • Dropdown and MegaMenu are the native Popover API, so the menu renders in the top layer and is never clipped, with native light-dismiss, Escape and focus-return. The shared _popover.ts positions them, reflows on scroll and resize, and adds arrow-key roving.
  • Checkbox, Radio, Switch are native inputs styled appearance-none with peer and :checked. Slider is a native range styled through its pseudo-elements. All zero-JS.
  • Select is a native <select>. For a searchable one there is ComboBox or AdvancedSelect, both of which state their JS requirement explicitly.

The primitives that do ship a script share one contract: _client.ts’s onReady, which handles load plus astro:after-swap re-init so a primitive still works after a view transition.

The full list

Tier 1 — Button, Input, Label, Textarea, Badge, Card (7 parts), Alert, Separator, Skeleton, Avatar.

Tier 2 — Accordion, Tabs, Tooltip, Breadcrumb, Pagination, Progress, Spinner.

Tier 3 — Dialog, Sheet, Dropdown, Select, Checkbox, Radio, Switch, Table.

Advanced form controls — Slider, InputNumber, ToggleCount, PasswordInput, PasswordStrength, ComboBox, AdvancedSelect, Searchbox.

Navigation & content — MegaMenu, List, Nav.

Content & motion — SplitText, CountUp, Marquee, Reveal.

Theme — ThemeToggle.

Shared internals — _dialog.ts, _popover.ts, _field.ts, _listbox.ts, _client.ts, _overlay.css, _Chevron.astro, password/strength.ts — are modules, not primitives, and are prefixed accordingly.

Three of them carry runnable checks: countUp.test.ts, password/strength.test.ts and _listbox.test.ts.

The three that reach a real page

Reveal, SplitText and CountUp. Plus two shared modules: _client.ts’s onReady, and theme-toggle/theme.ts, which the cabinet’s LIGHT/DARK switch imports without using the ThemeToggle component.

The other thirty-seven exist so /examples/ui can render them.

Why, and the rule it gives you

These primitives speak the document’s language — rounded-md, text-base, border-input, shadow-xs. Every one of those would have to be overridden to reach the tube’s --tv-u idiom. The contact form states the principle in one line:

Overriding a primitive down to nothing is not reuse, it is laundering.

So Global/LinkButtons.astro is not ui/button, and Global/PagesDropup.astro is not ui/dropdown. Both were written against native elements in the tube’s tokens instead.

The rule that follows is the one to actually apply:

Build a section from these primitives when it is a document-language surface, and from native elements in the tube’s tokens when it is inside the picture. Do not reach for a primitive because it exists.

If you are adding a page that lives outside the television — a documentation page, a dashboard, anything using .site-container — the library is there and it is good. If you are adding something inside the tube, write the element and use the tube’s type classes.

The three that do fit

They fit because none of them is a look — each is a behaviour that composes over whatever markup you give it:

SplitText splits a string into per-character or per-word spans, each behind its own clip mask, and animates them in on a stagger. Zero-JS: the split happens at build time and the motion is the owned animate-* catalog. It renders as any tag via as, so it can be the page’s <h1>.

CountUp counts a number up when it scrolls into view. The one primitive here that genuinely needs a script — there is no CSS way to tween a text node — so it goes through onReady and its arithmetic carries a test.

Reveal is a reveal-on-scroll wrapper composing the owned animation catalog, driven by the native scroll timeline (animation-timeline: view()), so it is zero-JS. It follows siteSettings.useAnimations — off means a plain pass-through wrapper — with a per-call animate override.

The dev catalog

/examples/ui renders every primitive in every variant, split across nine catalog sections. Open it in both light and dark mode after adding or changing a primitive: a missing token shows up instantly as an un-themed element, which is the fastest check there is for rule 4.

It emits no paths in a production build, but it does still contribute assets — 24 JS chunks against 6 without it, and a 22.6% larger shared stylesheet. Keep it while you are picking primitives; delete src/components/Sections/UiCatalog/ and src/pages/examples/ before launch. Deployment has the measured table.

Sections, and where a component belongs

Above the primitives sit sections — layout-free blocks of page content, 33 of them outside the catalog. The contract in Sections/README.md has one rule worth repeating here, because it is unusually early: a section used by two pages moves to Global/ at the second caller, not the third.

That is why Global/ is the largest section folder at 21 files. It holds the television itself (TvSet, TvControlPanel, TvScreenBar, FastextBar, PagesDropup) and everything that graduated out of a page folder when a second page drew it — SectionHead, TitleBlock, SpecStrip, CareerList, PullQuote and the rest.

Sub-parts are the distinction to keep straight: TvControlPanel lives in Global/ and is imported relatively by TvSet, but it has exactly one caller. That is what makes it a sub-part rather than a shared section that happens to live there.

Data flows in from the route. A section either receives its content as typed props or reads typed config itself — never both for the same data.

NEXT STEPIcons