Skip to content
AstroCraft Docs
On this theme

Components

src/components/ui/ is Medice’s own primitive library — 37 of them — built on tailwind-variants and consuming the token layer directly. It is not a vendored kit: there is no CLI, no component package and nothing to npx before you can edit a button.

src/components/ui/README.md is the contract, and every primitive follows it.

The five rules

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 has CardImage, CardHeader, CardTitle, CardDescription, CardAction, CardContent and CardFooter beside it.

Typed props are native attributes plus variants.

type Props = HTMLAttributes<"input"> & VariantProps<typeof field>;

Add & { … } for extras like href or src. Because the native attributes are in the type, anything HTML accepts passes through without the primitive having to enumerate it.

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

Tokens only, never raw colors. Every class resolves to a semantic token — bg-primary, text-foreground, border-input, ring-outline, rounded-md. This is what makes re-theming free.

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

---
import type { HTMLAttributes } from "astro/types";
import { tv, type VariantProps } from "@js/tv";

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;

Import tv from @js/tv, never from tailwind-variants

This is the one rule that will bite you silently if you break it, so it is worth understanding.

tv() resolves conflicting utilities through tailwind-merge, which classifies a class by matching it against Tailwind’s own scales. --text-body and --text-meta are this theme’s own steps, so a stock instance does not recognise them as font sizes — and because text-* is also the color prefix, it files them under text-color instead.

Both halves go wrong:

twMerge("text-muted-foreground text-sm text-ink-foreground text-body")
  stock      → "text-sm text-body"              // size ignored AND the colour token eaten
  configured → "text-ink-foreground text-body"

src/js/tv.ts is a createTV instance that teaches the merge about those two steps, which fixes both problems for every tv() in the codebase at once. tv.test.ts fails if the configuration regresses.

If you add a --text-* step, add it to that list in the same commit that defines it.

Interactivity: native first

The policy is zero-JS and native HTML first<details>, <dialog>, the Popover API, :has() and peer. A bundled <script> is reached for only when native will not do, and it is always a small ES module scoped to the primitive. Never a global plugin.

That policy is why the icon set inlines at build time and nothing lands in client JS, and why most of the 37 primitives ship no JavaScript at all.

The re-init contract

Any primitive that does ship a script goes through src/components/ui/_client.ts:

export function onReady(selector: string, wire: (el: HTMLElement) => void): void {
  const init = () => document.querySelectorAll<HTMLElement>(selector).forEach(wire);
  init();
  document.addEventListener("astro:after-swap", init);
}

It wires every matching element on load and again after each view-transition swap. Without it, a primitive works on first load and is inert after any client-side navigation — which is exactly the bug that is invisible in development if you always hard-refresh.

Sections follow the same contract: a section with a <script> is a two-line import of a wire* function, and the logic lives in the _-prefixed module beside it, where a check can reach it.

The shared internals

Files prefixed with _ in src/components/ui/ are shared machinery rather than primitives: _field.ts (form field wiring), _dialog.ts and _overlay.css (the overlay behaviour Dialog and Sheet share), _listbox.ts (Select, ComboBox and AdvancedSelect), _popover.ts, _client.ts and _Chevron.astro.

Sections do not export variants

Only ui/* primitives publish a tv() config. A section that needs one keeps it local — Global/Wordmark.astro and Cards/TopicCard.astro are the two examples in the codebase.

The reason is that an exported config is a public API. A primitive’s is meant to be composed; a section’s would be a promise about markup that exists to serve one page.

The catalog

/examples/ui renders every primitive in every variant on one page. It is dev-only: noindex, excluded from the sitemap, and it emits no paths in a production build.

After adding or changing a primitive, run pnpm lint && pnpm build, then open the catalog and look at it. A missing token shows up instantly as an un-themed element — which is a faster check than reading the diff.

Delete src/components/Sections/UiCatalog/ and src/pages/examples/ before launch. It builds no pages, but Tailwind still scans its markup, so its demo classes sit in the stylesheet every page loads; removing it cuts the shared CSS by about a quarter.

Adding a primitive

  1. Create src/components/ui/<name>/<Name>.astro and index.ts following the shape above.
  2. Import tv from @js/tv.
  3. Use only token utilities.
  4. Merge class through the config and set data-slot.
  5. If it needs a script, route it through onReady.
  6. Add it to the catalog and eyeball it.

The full list of primitives, with what each one is for, is in Components Reference.

NEXT STEPIcons