Components
src/components/ui/ is Urbic’s own primitive library — 45 of them, each a folder with a tailwind-variants recipe. It is not a vendored kit and not a CLI’s output; it is source you own, and its contract is written down in src/components/ui/README.md.
Nineteen are used by real pages. The other twenty-six ship for you to reach for, which is what /examples/ui exists for.
The five-rule contract
Every primitive follows the same shape, and a new one should too:
- One folder per primitive —
src/components/ui/<name>/<Name>.astroplus anindex.ts. A compound primitive keeps each part as its own.astrofile in the same folder. - Typed props are native plus variants —
HTMLAttributes<tag> & VariantProps<typeof config>, extended with an object for extras likehref. - Export the
tv()config, named after the component, so consumers can compose or extend it. - Tokens only, never raw colours.
bg-primary,text-foreground,border-input,ring-outline— neverbg-violet-700. This is what keeps dark mode and re-theming free. - Merge consumer overrides — destructure
class: className, spread...rest, pass the class through the config sotailwind-mergelets the last conflicting utility win, and tag the root withdata-slot="<name>".
---
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} />
Native-first interactivity
The policy is zero-JS and native HTML first — <details>, <dialog>, the Popover API, :has() and peer — with a small bundled <script> only when native will not do. Never a global plugin.
That is not aspiration. Dialog and Sheet are native modal <dialog> (a Sheet is a Dialog pinned to an edge by a side variant), sharing one delegated controller that binds once and survives view transitions. 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. Accordion is <details>. Tooltip is CSS-only. Checkbox, Radio and Switch are native inputs styled appearance-none with peer and :checked. Slider is a native range styled through its pseudo-elements.
The ones that do ship a script state why. Tabs re-inits on astro:after-swap and degrades to all-panels-visible. ComboBox, AdvancedSelect and Searchbox were deferred as “heavy JS” in the roadmap and built on request; each carries its own small script and a ponytail: note naming its ceiling. AdvancedSelect requires JavaScript — the zero-JS alternative is the native Select.
Files with a leading underscore are shared internals rather than primitives: _dialog.ts, _popover.ts, _disclosure.ts, _field.ts, _listbox.ts, _client.ts, _Chevron.astro, _overlay.css and _accordion.css.
Two controller stances
Worth knowing before you write a scripted primitive, because the library uses both deliberately.
Delegated — _dialog.ts, _popover.ts and _disclosure.ts attach one pair of listeners to document for every instance on the page. They bind once and survive view transitions with no astro:after-swap re-init.
Per-instance — _client.ts exports onReady, the load-plus-astro:after-swap contract every scripted primitive shares. Tabs, InputNumber, PasswordInput and the listbox trio use it.
Reach for delegation when the behaviour is a document-level interaction; use onReady when a component genuinely owns per-element state.
The one non-discoverable primitive
Collapse grows a region from nothing to its own content height with grid-template-rows: 0fr → 1fr, so no height is ever hard-coded. Two things make 0fr actually collapse and neither is discoverable: the grid item needs min-h-0 (overflow-clip is not a scroll container, so unlike overflow-hidden it does not zero min-height: auto), and it must carry no padding of its own (padding floors the track below min-height). Get either wrong and the row silently stays at full height — the reveal never animates, only the visibility flip shows, and lint, check and build all pass.
Put your padding on an element inside the slot. That is why Collapse renders its own clipped inner box rather than letting the caller pass one.
The second copy
src/components/primitives/ is a second copy of the primitives that the CMS imports. It exists so the admin has no dependency on your UI library — you can restyle, rename or delete anything in src/components/ui/ without breaking an admin screen.
The duplication is measured rather than forgotten: primitiveDrift.test.ts counts what has diverged between the two. src/components/primitives/ is package territory and gets replaced wholesale on a CMS update, so do not edit it.
Cards
src/components/Cards/ holds the content-aware compositions: ProjectCard, NoteCard, ServiceCard, PartnerCard, TestimonialCard and GalleryPlate. A card knows about a kind of content; a primitive does not. Cards/README.md is the contract.
Adding a primitive
Follow the five rules, build it from tokens, add it to the catalog under src/components/Sections/UiCatalog/, then run pnpm lint && pnpm build and open /examples/ui in both light and dark. A missing token shows up instantly as an un-themed element — that eyeball pass is the check the library actually relies on.
If the primitive will be used inside a section the CMS composes, remember that the composer reads a section’s props from a self-contained interface Props. See Composing pages and the overrides block in Configuration.
The full inventory is in Components Reference.