UI Components
Urengi ships its own UI primitive library — 44 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.
Preline was used as a markup and states reference while building them, which is normal and is why some of the DOM shapes will look familiar. Nothing from it ships: there is no preline.js, no plugin, no runtime.
For the full inventory — every primitive, its parts and its variants — see Components Reference. This chapter is the pattern.
The contract — five rules
1. One folder per primitive. src/components/ui/<name>/<Name>.astro plus an index.ts. A compound primitive keeps each part as its own .astro file in the same folder.
src/components/ui/<name>/
├── <Name>.astro # the component + its exported tv() config
└── index.ts # re-exports { <Name>, <Name>Variants } and a default
2. Typed props are native plus variants.
type Props = HTMLAttributes<"button"> & VariantProps<typeof button> & { href?: string };
The native attribute type is what makes aria-*, data-*, id and the rest work without being declared.
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 — bg-primary, text-foreground, border-input, ring-outline, bg-error. This is what keeps dark mode and re-theming 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>" as a styling hook.
Button is the whole contract in forty lines:
---
import type { HTMLAttributes } from "astro/types";
import { tv, type VariantProps } from "tailwind-variants";
type Props = HTMLAttributes<"button"> & VariantProps<typeof button> & { href?: string };
export const button = tv({
base: [
"inline-flex items-center justify-center gap-2 rounded-md font-medium whitespace-nowrap",
"transition-all outline-none focus-visible:ring-3 focus-visible:ring-outline/50",
"disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
],
variants: {
variant: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90",
outline: "border-border text-foreground hover:bg-muted border bg-transparent",
ghost: "text-foreground hover:bg-muted",
},
size: { sm: "h-9 px-3 text-sm", md: "h-11 px-4 text-base", lg: "h-12 px-6 text-lg" },
icon: { true: "px-0" },
},
compoundVariants: [
{ icon: true, size: "sm", class: "w-9" },
{ icon: true, size: "md", class: "w-11" },
{ icon: true, size: "lg", class: "w-12" },
],
defaultVariants: { variant: "primary", size: "md" },
});
const { variant, size, icon, href, class: className, ...rest } = Astro.props;
const Tag = href ? "a" : "button";
---
<Tag href={href} class={button({ variant, size, icon, class: className })} data-slot="button" {...rest}>
<slot />
</Tag>
Note the Tag switch: pass href and you get an <a>, omit it and you get a <button>. Several primitives do this, and it is why a link styled as a button is still a link.
Using them
import { Button } from "@components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@components/ui/card";
<Button variant="outline" size="lg" href="/pricing/">See pricing</Button>
<Card variant="elevated">
<CardHeader><CardTitle>Evidence vault</CardTitle></CardHeader>
<CardContent>…</CardContent>
</Card>
Every primitive folder has an index.ts re-exporting a named component, its variants object and a default, so both import styles work.
Overriding
Pass class. tailwind-merge resolves the conflict in your favour:
<Button class="w-full rounded-full">Start free trial</Button>
rounded-full replaces the base’s rounded-md rather than fighting it in the cascade — that is what the merge is for, and it is why rule 5 exists.
For a systematic change, extend the exported config instead of overriding at every call site:
import { button } from "@components/ui/button/Button.astro";
import { tv } from "tailwind-variants";
export const heroButton = tv({ extend: button, base: "shadow-lg" });
One caveat worth knowing when an override does not take: tailwind-merge only replaces a class it recognises as conflicting at equal specificity. A base class written as hover:bg-muted and an override written as hover:bg-primary merge cleanly; an override that reaches the same property through a different modifier does not, and then emit order decides. The Header’s pill restates every colour state with the same modifiers the primitives use for exactly this reason.
Native first
The interactivity policy is a ladder, and most primitives stop on the first rung:
<details>— Accordion.- Native modal
<dialog>— Dialog, and Sheet, which is a Dialog pinned to an edge via asidevariant. - The Popover API — Dropdown and MegaMenu, so the menu renders in the top layer and is never clipped, with native light-dismiss, Escape and focus return.
peerand:checked— Checkbox, Radio, Switch, allappearance-noneover real inputs.:has()— the pricing page’s billing toggle, zero JS.- CSS only — Tooltip, Marquee, and the scroll-driven motion primitives.
A bundled <script> comes out only when native will not do. Never a global plugin.
The client lifecycle
Every scripted primitive shares one contract, in 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);
}
ClientRouter is mounted by BaseHead when siteSettings.useViewTransitions is on, and a view transition replaces the DOM without reloading the page — so anything wired on load is wired to elements that no longer exist. onReady runs the wiring now and again after each swap.
If you add a primitive with a script, use onReady. A primitive that works on first load and stops working after one navigation is this bug, every time.
Two primitives deliberately do not use it: _dialog.ts and _popover.ts bind once, at the document level, with delegated listeners. Delegation survives a swap on its own, so re-initialising would only add duplicate handlers.
The shared internal modules
A leading underscore means “not a primitive”, which is why the inventory never lists one:
| Module | Owns |
|---|---|
_client.ts |
the onReady re-init contract |
_dialog.ts |
one delegated controller for Dialog and Sheet |
_popover.ts |
placement, roving arrow keys and aria-expanded for Dropdown and MegaMenu |
_listbox.ts |
filterByText / nextIndex / createActiveDescendant for the filterable trio |
_field.ts |
the shared field look — size and validation state |
_overlay.css |
the entry/exit transitions, backdrop scrim and modal scroll-lock |
_Chevron.astro |
the one disclosure chevron glyph |
Extraction happens at the second consumer, not the first, and the library says so out loud: a note in the README instructs whoever builds a third scroll-reveal primitive to extract _reveal.ts, because SplitReveal and StaggerReveal already share a contract and two is the tolerable amount of duplication.
The catalog is the check
After adding or changing a primitive: pnpm lint, pnpm build, then open /examples/ui and eyeball it in both light and dark mode. A missing token shows up instantly as an un-themed element, and there is no faster way to find one.
About twenty of the 44 are used by no page in the theme. That is inventory for the site you build next, and the catalog is how you shop it before rebuilding something that already exists.
Cards
src/components/Cards/ sits between primitives and sections. A card is a content-aware composition built from the ui/card parts that knows a data shape.
A card takes the collection entry, not its fields. BlogCard receives a CollectionEntry<"blog"> and destructures inside; a section maps over entries and passes them straight through. Spreading the fields at the call site puts the data shape in two files and makes every new field a two-file edit.
Its tv() config goes in a _<name>Card.ts sidecar, not inline. astro/no-exports-from-components is switched off only for ui/ and svg/, so a component here cannot export its own recipe. Cards with no variants need no sidecar.
Three ship — BlogCard, CaseStudyCard, IntegrationCard — one per collection with a listing, which is the shape to copy for a fourth.
Adding a primitive
src/components/ui/<name>/<Name>.astroplusindex.ts.- Follow the five rules. Copy the nearest existing primitive rather than starting from a blank file.
- Native first. If you must script it, use
onReady. - Add it to a panel in
Sections/UiCatalog/in every variant. - Add it to
src/components/ui/README.md— that file is the source of truth for both the contract and the inventory, and a live contract pointing at a stale list is how an inventory silently falls behind. pnpm lint && pnpm build, then eyeball the catalog in both themes.