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 exportedtv()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
wiredset and returns, with no error anywhere.Carouselfirst composedStaggerRevealas its own root and its arrows were simply dead.Reuse a sibling’s
tv()config freely —PaginationLinkreusesbutton,CtaButtoncomposes 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, onesidevariant. - MegaMenu reuses Dropdown’s popover controller. One delegated
_popover.tsplaces both, binds once at document level and survives view transitions. PaginationLinkreuses thebuttonconfig rather than redefining one.CtaButtoncomposes 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 composefieldBaseandfieldStaterather 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
src/components/ui/<name>/<Name>.astroplusindex.ts.- Follow all five contract rules.
- Check the interactivity ladder before writing a script — native first.
- If it ships a script, use
onReadyfrom_client.tsand pass{ signal }to every listener. - If it contains non-trivial pure logic, put that logic in its own dependency-free module with a
*.test.tsbeside it.pagination/window.ts,carousel/scroll.ts,count-up/count.ts,password/strength.ts,text-reveal/lines.tsandnav-highlight/geometry.tsall do this. - 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.