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.
| Tier | Budget | Without JavaScript | What it means |
|---|---|---|---|
| Tier 0 RSC — zero client JavaScript | 0 kB | Passes 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 kB | Degraded 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-component | Renders 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 layerLower 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