Skip to content
AstroCraft Docs
On this theme

UI Components

Develi ships its own UI primitive library — 46 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.

This chapter is the pattern. Components Reference is the catalog of what exists.

The contract — five rules

Every primitive follows all five.

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, and so on.

2. Typed props are native plus variants.

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

Add & { … } for extras like href or src. This is what makes a primitive accept every attribute the underlying element accepts, with no allow-list to maintain.

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

4. Tokens only, never raw colors. 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, and tag the root with data-slot="<name>".

The shape

---
// src/components/ui/field/Field.astro
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} />
// index.ts
import Field, { field } from "./Field.astro";

const FieldVariants = { field };

export { Field, FieldVariants };
export default Field;

Passing class: className through the tv() call rather than concatenating it is what makes overrides work: tv runs tailwind-merge, so the last conflicting utility wins and you never end up with px-2 px-4 on one element.

<Button size="lg" class="w-full">Send</Button>
<Button class="px-8">Wider than its size variant</Button>

One Astro compiler quirk to know about. Astro hoists exported consts out of the render function into module scope, so an exported tv() config cannot reference a non-exported local declared in the same frontmatter. If you find a default value duplicated between an exported variant config and a local prop default, that is the compiler forcing it, not a DRY smell.

Interactivity policy

Native HTML first, then a tiny script, never a plugin.

The ladder in practice: <details> for disclosure, <dialog> for modals, the Popover API for menus, :has() and peer for state-driven styling, appearance-none plus :checked for custom controls. A bundled <script> only when native genuinely will not do.

The results are worth stating because they set expectations. Accordion is <details> with an exclusive name. Tooltip is CSS-only. Dialog and Sheet are native modal <dialog> — a Sheet is a Dialog pinned to an edge via a side variant. Dropdown and MegaMenu are the native Popover API, so the menu renders in the top layer and can never be clipped by an overflow ancestor. Checkbox, Radio and Switch are native inputs. Slider is a native <input type="range"> styled through its pseudo-elements.

Of the 46, thirteen ship a script.

Deliberately out of scope

These are not primitives here, because each needs heavy JS or a third-party library: Datepicker, Time Picker, Color Picker, data-grid/sortable tables (the Table primitive is static styling), Charts, Maps, WYSIWYG, Drag-and-Drop, File Upload, Tree View, Layout Splitter, Custom Scrollbar.

Add one per project if a build needs it. Compositions like a Navbar or Sidebar are built from these primitives and belong in src/components/Sections/, not here.

The client lifecycle

Every primitive that ships a script goes through one shared module, ui/_client.ts, and it owns the whole lifecycle rather than just setup. One rule: an element is wired at most once, and stays wired until its cleanup runs.

Concretely: wire runs on load and after each astro:after-swap; the wired set is a WeakSet, so no markup is polluted and entries vanish with the elements a view transition discards; and on astro:before-swap every registered cleanup runs and the set empties, so an element that survives a swap via transition:persist is wired again from scratch rather than left torn down but still marked wired.

Teardown is onReady’s job, not yours. It hands wire an AbortSignal and aborts it itself, so your whole obligation is to pass { signal } to every addEventListener:

function wire(root: HTMLElement, signal: AbortSignal) {
  root.addEventListener("click", handler, { signal });   // released for you
  const ro = new ResizeObserver(measure);
  ro.observe(root);
  return () => ro.disconnect();   // the only thing a signal cannot reach
}

Return a cleanup function only for what a signal cannot release — an observer to disconnect, a frame to cancel, DOM the primitive appended. Exactly two primitives do: NavHighlight (a ResizeObserver) and Carousel (an observer, a pending frame, and its clones).

The corollary cost a rewrite, so it is worth stating loudly: two primitives that both ship a script cannot share a root element. Whichever registers second finds the element already in the wired set and returns, with no error anywhere. Carousel first composed StaggerReveal as its own root and its arrows were simply dead.

Reuse a sibling’s tv() config freely — PaginationLink reuses button, CtaButton composes it. But give each script its own box.

Composing rather than duplicating

The library leans hard on reuse, and the patterns are worth copying:

  • Sheet reuses Dialog. Same <dialog> shell, same trigger and close parts re-exported under new names, one side variant.
  • MegaMenu reuses Dropdown’s popover controller. One delegated _popover.ts places both, binds once at document level and survives view transitions.
  • PaginationLink reuses the button config rather than redefining one.
  • CtaButton composes Button + RollText + Icon and adds no new mechanism. It exists because the treatment was inlined at three call sites, which is exactly how its arrow drifted to 20px in one and 24px in the others.
  • Five field-shaped primitives share _field.ts — Input, Textarea, Select, ComboBox and AdvancedSelect all compose fieldBase and fieldState rather than each translating the field look independently.

Shared internal modules carry a leading underscore and are not primitives: _client.ts, _field.ts, _dialog.ts, _popover.ts, _listbox.ts, _reveal-once.ts, _Chevron.astro, _overlay.css, _reveal-once.css.

Sidecar CSS

Three things do not express as Tailwind utilities, and each lives in a leading-underscore stylesheet imported from a component’s frontmatter: ui/_overlay.css (dialog and sheet transitions), ui/accordion/_accordion.css (animating ::details-content), and the reveal primitives’ _reveal-once.css.

A sidecar must restate the reduced-motion guard. An unlayered sidecar outranks the layered global guard in motion/index.css, so motion declared in one is not covered by it. Both shipped sidecars carry their own @media (prefers-reduced-motion: reduce) block. If you write a third, do the same.

Cards — the bridge tier

src/components/Cards/ holds seven compositions that know about a data shape: CaseCard, NumberedCard, PostCard, ServiceCard, SupportCard, TeamCard, TestimonialCard. They are built from the ui/card primitives and are what sections map over.

The split is the useful part: ui/card stays generic and knows nothing about a blog post; PostCard takes a CollectionEntry<"blog">. When you add a card for your own data, it goes here, not in ui/.

Adding a primitive

  1. src/components/ui/<name>/<Name>.astro plus index.ts.
  2. Follow all five contract rules.
  3. Check the interactivity ladder before writing a script — native first.
  4. If it ships a script, use onReady from _client.ts and pass { signal } to every listener.
  5. If it contains non-trivial pure logic, put that logic in its own dependency-free module with a *.test.ts beside it. pagination/window.ts, carousel/scroll.ts, count-up/count.ts, password/strength.ts, text-reveal/lines.ts and nav-highlight/geometry.ts all do this.
  6. Add it to the catalog under Sections/UiCatalog/.

The check

Open /examples/ui and eyeball it in light and dark — the toggle is in the header. A missing token shows up instantly as an un-themed element. Then run the chain:

pnpm lint && pnpm check && pnpm build && pnpm test

The catalog is the fastest way to find out whether the thing you are about to build already exists. Check it before you write.

NEXT STEPIcons