Skip to content
uiify

Architecture

uiify has five layers, each published on its own and never importing the one above. One rule shapes all of them: a component must work before hydration.

The hydration gap

Streaming SSR leaves a gap between first paint and the end of hydration, and under Suspense every late boundary has its own. In that gap, a useState accordion is visible but inert, while a <details> accordion works from first paint.

Two documents render the same accordion without any <script> tag: one built on `<details>` and one built on `useState`. Open both: the first opens, the second cannot. View the source to confirm.

The goal is zero unnecessary renders, zero provider-wide fan-out and zero synchronous layout work from library JavaScript — not zero renders, since state must render somewhere. JavaScript is zero by default and thin only where accessibility requires it.

Tiers

Each component declares a tier in a manifest that the build reads. The manifest drives the size budget, the RSC-purity lint, the Playwright suite and the tier badge on each component page.

TierBudgetWithout JavaScriptWhat it means
Tier 0
RSC — zero client JavaScript
0 kBPasses the no-JavaScript suite in full.State lives in a native element, or there is no state. Every React hook is banned, including useMemo and useRef.
Tier 1
RSC shell + thin client island
≤ 1 kBDegraded behavior, documented per component.The native element still owns the state. JavaScript only syncs ARIA or adds what the platform lacks, such as hover intent or auto-dismiss.
Tier 2
Client component — pre-hydration SSR
per-componentRenders meaningful markup before hydration.A single client entry. The server emits native-first markup where it can; keyboard navigation, filtering and ARIA sync wait for hydration.

Tiers are also a migration tool: gates switch on per component, so the architecture tightens one component at a time.

The layer stack

@gunkin/uiify/hooks       SSR-safe React hooks — the substrate
@gunkin/uiify/core        headless behaviour: focus scope, dismiss layers, anchoring
@gunkin/uiify/elements    native-mechanism wrappers — zero client JavaScript
@gunkin/uiify/components  unstyled components, RSC by default
@gunkin/uiify/styles      optional CSS design layer

Lower layers never import higher ones. @gunkin/uiify/elements is the strictest: it may import only @gunkin/uiify/core/render and React types.

Why elements are a public layer

Most apps never import @gunkin/uiify/elements directly. It is published for two reasons.

First, it makes the design verifiable. When a component page says _built on elements/dialog_, that element page shows the exact markup and data-* contract underneath.

Second, composition: if the component anatomy does not fit your design, build your own on the same wiring instead of reimplementing the mechanism.

A module joins the layer only if it meets all five rules: one native mechanism, no JavaScript behavior, two or more consumers in @gunkin/uiify/components, state a CSS selector can read, and less duplicated markup rather than more indirection.

Modules are named after platform features — details, dialog, popover, invoker — not UI concepts. A component never shares a name with an element, which is why Modal is built on dialog. Browse Elements

Why React context is banned in Tier 0

Moving state into the DOM achieves nothing if the parts still talk through React context. Server Components cannot use context, so a Root and Item that share a provider cannot render on the server, however native their markup.

The wiring must survive serialization, so parts connect through HTML: <details name> for grouping, commandfor and popovertarget for references the browser resolves, and ids derived from props. A useId value cannot reach sibling parts without context.

<Accordion id="faq">
  <Accordion.Item value="shipping">   {/* id="faq-shipping" */}

The cost: a root needs an id. Where composition is unnecessary, a component takes data and renders the tree itself. See Server components