Skip to content
AstroCraft Docs
On this theme

Components

src/components/ui/ is Indexa’s own primitive library — 37 folders and 84 components, each built on tailwind-variants. 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.

Thirteen are used by real pages. The other twenty-four ship for you to reach for, which is what /examples/ui exists for. Components Reference lists all of them by what they do.

The five-rule contract

Every primitive follows the same shape, and the rules are worth knowing before you write the thirty-eighth.

One folder per primitive. ui/<name>/<Name>.astro plus an index.ts. A compound primitive keeps each part in the same folder — card/ holds Card, CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter and CardImage.

Typed props are native plus variants. HTMLAttributes<"button"> & VariantProps<typeof config>, so every native attribute works without being re-declared and the variants are typed from the recipe.

The tv() config is exported, named after the component, so a consumer can compose or extend it instead of forking it. PaginationLink reuses button’s config rather than defining a second one.

Tokens only, never raw colours. Every class resolves to a semantic token. This is the rule that makes a rebrand free, and the one that is easiest to break in a hurry.

Consumer overrides merge. Destructure class: className, pass it through the config so tailwind-merge lets the last conflicting utility win, spread ...rest, and tag the root with data-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: ["…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 JavaScript unless the platform genuinely cannot do it, and the library holds to it further than most:

Accordion is <details>. Tooltip is CSS only. Checkbox, Radio and Switch are native inputs styled with appearance-none and peer. Select is a native <select>. Slider is <input type="range"> styled through its pseudo-elements. Table is a styled <table> in a scroll wrapper. List is <ul>/<ol>. None of those ship a byte of script.

Dialog and Sheet are native modal <dialog> — a Sheet is a Dialog pinned to an edge by a side variant, reusing Dialog’s trigger, close and content parts. Dropdown and MegaMenu are the native Popover API, so a menu renders in the top layer and can never be clipped by an ancestor’s overflow.

Where a script is unavoidable, it is shared rather than duplicated. _dialog.ts is one delegated controller for every dialog and sheet on the page — openers carry data-dialog-open="<id>", closers data-dialog-close, backdrop light-dismiss included, Escape native. It binds once and survives view transitions. _popover.ts does the same job for Dropdown and MegaMenu, adding placement, reflow on scroll and resize, arrow-key roving and aria-expanded syncing. _field.ts is the shared field look, _listbox.ts the filter/active-descendant helpers the three searchable controls share, and _client.ts the onReady load-plus-astro:after-swap contract every scripted primitive uses.

Seven primitives ship their own small script: Tabs, InputNumber, PasswordInput, PasswordStrength, ComboBox, AdvancedSelect and Searchbox. Each states its ceiling in a ponytail: note, and AdvancedSelect says the thing worth repeating — it requires JavaScript, and the zero-JS alternative is the native Select.

What the site uses

Thirteen primitives carry the whole marketplace:

Primitive Where
button 60 files — every CTA, pager control and toolbar action
reveal 69 files — the scroll-entrance wrapper
breadcrumb Nine page heroes
input Ten forms and search fields
accordion The three FAQ bands
card Four of the seven card compositions
badge, nav, sheet The header and footer
select The home hero and the saved toolbar
pagination The blog archive
password The sign-in form’s field and strength meter
skeleton The saved dashboard’s loading rows

dialog is in there too, reached through sheet. Three of the seven cards — CarRecordCard, SpecialistCard, ArticleCard — are built from markup rather than the card primitive, because the design gives each a structure the primitive’s parts would have to be fought into. That is a fair reason to leave a primitive alone; “it was quicker” is not.

Adding one

Copy the nearest existing primitive, follow the five rules, and add it to the catalog under src/components/Sections/UiCatalog/ so it renders in every variant at /examples/ui. Then pnpm lint && pnpm build, open the catalog, and look at it — a missing token shows up instantly as an unthemed element, which is faster than reading the diff.

If it carries non-trivial logic, leave a *.test.ts beside it. password/strength.ts is the model: the scoring rules are pure, the check asserts six inputs, and pnpm test runs it with no framework. That is the house rule, and pnpm test fails when it finds no checks at all, so it is enforced by tooling rather than memory.

NEXT STEPIcons