Conventions
How a file in this library is written — the rules for changing it, rather than for using it.
Everything else on this site answers how do I use this? This page answers how do I write one?, and it is addressed to whoever adds a component, changes a recipe or lands a guard. If you are building an application with the library, Styling and Theming are the pages you want.
It states nothing the other pages state. Philosophy has the three concerns, the
layers, admission and the client boundary; Styling has the recipe every component
follows and the line between a recipe and an inline utility; Theming has the
token vocabulary and the two things -foreground means.
The reference, and what overrules it
Ark for behaviour, Shark UI for appearance and surface — and the export list is surface, because a name is the first thing a consumer meets. Two house rules once pointed opposite ways over that, on identical evidence, and neither yielded. So the order is written down:
The reference governs the surface; a measurement governs the reference; nothing else governs either.
Philosophy is where that sentence sits beside what it decided. Five things it does not say on its own:
- A measurement is a number, what it was measured against, and a threshold it crosses. The diluted focus ring measured 1.29:1 against the 3:1 WCAG 1.4.11 asks of a component state; without the last clause it is a fact about a colour, not a reason. A failing test counts when what it asserts is a measurement or an external standard, and does not when it asserts a house preference — that is the preference wearing a test's clothes. The question is: would the divergence still be right if whoever wanted it left?
- A measurement licenses exactly the divergence it measures, and no neighbouring one. White on
the status fills, measured at 2.13–3.81:1 against AA's 4.5, bought
destructive-contentand its three siblings. It bought nothing else in those recipes. - A house principle loses to the reference, however good it is and however correctly it was applied. "A name needs a second call site" is right everywhere "a name Shark ships is ours" does not overrule it, and it was overruled while being correct.
- A reference that is wrong is still the reference, and the first move is the fix that is not a
divergence — a prop we already own, an upstream report.
Stepskeeps Zag'srole="tab"with no key handling anywhere in Zag and Shark wrapping it unchanged, because stripping the role leavesaria-selectedon a non-widget role, and stripping all of it means hand-rolling the ARIA the accessibility clauses below forbid. Upstream changes that, or our ownlinearprop. Discomfort does not. - Silence returns the question to the house rules; it does not open it. Shark ships no
pin-inputfile at all, so parity neither grants nor refusesPinInput, and admission rule 2 decides it like anything else of ours.
Where this does not decide, the owner does — the two references disagreeing, a name that matches while its binding does not, a product question in accessibility's clothes. Record the case as undecided rather than arguing it into a branch. A tie-break claiming more than it settles is the same defect as a guard claiming more than it proves.
Structure and props
ark.*on every part that renders a DOM element — simples, composites and layouts, with no exemption.<ark.div>renders adivand forwards everything, so it costs nothing at runtime; what it adds isasChild, universally. Type props asReact.ComponentProps<typeof ark.div>, neverComponentProps<"div">. A component that promisesasChildin a doc comment withoutark.*is promising something it does not have.data-sloton every targetable part, spelled<component>-<part>. Three rules keep it a contract rather than decoration, and each has a scar on it:- The primitive owns its slot: write
data-slot={slot ?? "<component>-<part>"}after{...rest}, never before. Before the spread, a caller'sdata-slotwins and the primitive's own disappears, taking every recipe that selects it with no error and no visible symptom. slot?: stringis the sanctioned way to re-slot a part, and the only one. Never declare a type for it and never add aSlotPropsinterface — React'sHTMLAttributesalready carriesslot, so everyReact.ComponentProps<typeof ark.*>has it. The exception is a props type extending an Ark machine's props rather than HTML attributes, which has noslotto inherit;SidebarPropsis the one, and it already had to declareclassNamefor the same reason. Renaming is then explicit, erasing is impossible, and a guard can tell the two apart — which it could not while both were spelleddata-slot.- Never write a bare
data-sloton one of our components — only on the DOM element itself. TypeScript will not stop you: it does not typecheck a hyphenated JSX attribute, sodata-slotis accepted on any component and silently does nothing on the ten Ark roots that render no element at all. Asking forslotinstead is what surfaced those ten in onetscrun. UnderasChildthe child wins, so push the slot down:<Button slot="combobox-trigger">, not adata-sloton the trigger that wraps it.
- The primitive owns its slot: write
- A layout tree is children, never an attribute. If a prop's value is markup, it is children. A
record or array of
ReactNodes is a layout tree written as an attribute: the caller cannot reorder the regions, wrap one, spreadclassName/data-*/aria-*/ a handler onto one, or useasChildon one.CardHeader, not<Card header={…} />.- The line is what the value is, not whether it is an array. A collection a machine
navigates is data and belongs in a prop — Ark's
createListCollectioncannot be built from children, soFacetFilter'sitems,Tour'sstepsand a faceted column's values are correct. Numbers, ids and strings are data.ReactNodein the field type is the tell, and aseparatorBefore: booleanis the confession: a separator between children, in a shape that has no children to put one between. - When you want the ergonomics of a list, take a render prop.
FieldArray'schildren: (index) => ReactNodekeeps composition and still owns the loop. - A convenience that flattens a compound into an array is rung 1 of
the ladder wearing rung 5's clothes. It goes
in
docs/examples/, where it is a demonstration rather than an API.
- The line is what the value is, not whether it is an array. A collection a machine
navigates is data and belongs in a prop — Ark's
Accessibility, two clauses, because "accessibility comes from Ark" is not true of this codebase: Ark ships no sidebar, app shell, field array or facet filter, and our chart, table and editor layers are ours.
- Where Ark ships an equivalent, use it. Never hand-roll focus, keyboard or ARIA it already
provides. Check
@ark-ui/react/dist/components/— resolvable frompackages/ui, not from the repository root — before writing a state machine. - Where Ark has none, the bespoke part documents its ARIA contract in a comment and is covered
by a test, and never declares a composite role (
toolbar,listbox,tree,grid,tablist) without implementing that role's keyboard contract. Arole="toolbar"whose items are each independently tabbable, with no roving focus, is worse than no role at all: it promises assistive tech a navigation model that is not there.
Naming
Files are kebab-case, matching Shark. A few older files are PascalCase — drift, not a semantic
marker. Never rely on case-insensitive resolution: CI is case-sensitive even though macOS is not, so
a case-mismatched import is a failure you cannot reproduce locally. No such pair exists today and
none can be added by accident: tsconfig.base.json sets forceConsistentCasingInFileNames, and two
paths differing only by case cannot both be checked out here in the first place.
The export rules — flat rather than dot-notation, the bare name for an Ark machine, *Root for a
compound of ours, and the RSC reason a namespace is not merely a second dialect — are on
Styling, because they are what a consumer types.
The client boundary, past the rule
Philosophy states the rule: a file gets "use client" iff
it itself is stateful, and importing a stateful module is not a reason. Three things that follow
from it and belong to whoever writes the file:
- If you want the directive because something you import is stateful and lacks its own, fix that module instead. That is the only form of the transitive case, and the first half of the rule makes it impossible.
- Both halves are enforced by
packages/ui/src/client-boundary.test.ts. The one case it cannot see is a browser global reached at module scope; there are none today, and the rule still bans them. - The build must preserve the directives:
preserveModulesplusrollup-plugin-preserve-directives. Rollup strips them when it merges modules, and that failure is invisible to any Vite-based harness, because Vite ignores"use client"entirely.pnpm smokeand the docs App Router build are what catch it.
Comments
A comment is code that cannot be tested, so it decays silently. Write one only when the code cannot carry the fact. Four are required; everything else is absent by default.
- A measurement. A contrast ratio, a ΔE, a byte count, a timing. Cite the number and what it was measured against.
- A decision a naive reader would undo. The reason, not the history. The test: if this line were deleted, would the next reader change the code back?
- An ARIA contract, on every bespoke part where Ark ships no equivalent. Name the roles, the keys, and what is announced.
- A constraint invisible in the code — that Tailwind scans comments, so a retired class cannot be quoted; that a hoisted object exists because a hook memoises on identity.
Do not write what the code says; a @param or @returns restating a type (we use prose JSDoc,
with @default, @link and @example as the only tags); an empty banner; commented-out code
(there is none — keep it that way); or any legacy, migration or back-compat note, because nothing is
published and such a comment explains code that should not exist.
Six rules that keep a required comment true
The comment problem here is not ceremony — there is no commented-out code and restating comments are rare. It is duplication and staleness: one paragraph written out in many places, several copies of which went stale independently.
- Write one copy — one per package that enforces it, and never two inside one package. State a fact where it is enforced and point at it from everywhere else. Where two packages enforce the same thing independently they each get a copy, because the lower one may not be sent into the other's internals for its reasoning. Two copies inside one file is the version that is never defensible.
- Date a measurement, and name the set it was taken over — by a name that exists in the source.
A ratio read off
tokens.cssis a claim about a build artefact and the nextpnpm check:generatedcan falsify it, so write "measured 2026-07" rather than "today's border ships". A tally whose corpus has no name cannot be re-derived, only deleted: "measured over 118 seeds" cost an hour to reconstruct and turned out to be a set that had since changed size. A count of call sites rots the same way; prefer a test that counts. - Prose that is hashed, serialised or rendered is data, not a comment. Correcting it is a code change with a changeset, and it may not be safe at all — a stale tally inside a digest cannot be fixed, because the string is what the digest claims. Before editing any string, check whether something downstream reads it.
- Fix the code, not the prose — unless the wrong value is pinned. A comment describing a wrong fixture is usually a wrong fixture. Try the code fix first. When assertions depend on the wrong value, correct the comment and say in it that the value is pinned and why, or the next reader makes the same attempt and reverts it.
- Cite a symbol, never a line. A line reference moves silently; several here did.
- Attach the comment to the thing. A
/** */followed by a blank line documents nothing, and no editor will show it.
Keep a "used to be X" only when it names the mistake it prevents, and say which mistake.
Tests and guards
pnpm test runs vitest across the packages; packages/ui sets environment: "jsdom" and the rest
run under node. The minimum bar for a component is a test that renders it and asserts the behaviour
its recipe depends on. Two conventions beyond that:
- A test is a specification.
describe("a styled list keeps its semantics"),it("bites on a stripped list with no role, and not on one that declares it"). Name the claim, not the function. - A deleted component gets a tombstone assertion in
packages/ui/src/index.test.ts, with the reason it went. That is what stops it being rebuilt.
What "repo-wide" means is packages/ui/src/guard-corpus.ts, and it was a lie until 2026-08-20:
every appearance and boundary guard walked from its own directory, so the corpus was
packages/ui/src and @kanzo-tech/ai shipped ten modules none of them had read a line of. The
corpus is now derived — a package is scanned when it declares tailwind-variants, which is the
tell that it writes appearance in this house's idiom — and it throws at import if that resolves to
fewer than two packages, because every rule built on it asserts an absence and a shrunken corpus
passes all of them. A failure names its file <package>/<path under src>: ui/simples/button.tsx,
ai/message.tsx. Extend the corpus; never copy a guard into a second package.
The guards are the *.test.ts files beside the source they scan — packages/ui/src and
packages/theme/src. There is deliberately no table of them here. One existed for months and rotted
in exactly the way this page warns about: it named three files that no longer exist, and missed five
that do. A routing aid nobody re-derives is worse than the ls it replaces, and every one of them
enforces more, and less, than a row could say. Read the file.
Three things a guard owes
- Say what it cannot prove.
pnpm smokecompares the bytes of a built artefact, so it catches Rollup dropping a"use client"directive and cannot tell you the boundary is in the right place; only the docs RSC build evaluates that. A guard that states its own blind spot is one nobody over-trusts. - Mutation-test it. Write the violation, watch the assertion fail with the message you meant,
then remove it. A guard nobody has seen fail is a guard nobody has tested. The leak check in
pnpm smokeguarded two symbols that had never existed in this repository, so it could not fail and did not, for months. - Parse, do not grep, and never let the corpus shrink silently.
charts/chart-inputs.tsxcontained a raw NUL byte, which madefile(1)and everygrep -Itreat the largest file in the chart layer as binary — a grep-based guard would have skipped it and reported a pass. The scan now asserts no source file contains one. The general rule: a guard that can silently not see part of its corpus is worse than no guard, because it reports the same green as a real pass.
Logical properties, never physical — border-e / border-s, side="start" | "end". One code
path mirrors correctly under RTL, and RTL is the page about it. The test is the rule; if
you need an exception, add it to that file's allowlist with the reason it does not depend on reading
direction.
When a mistake recurs, write the test, not the paragraph. Three is where a rule becomes a guard.
Distribution
The package split and the optional-peer subpaths are on Installation. One rule
that is not: semver goes through changesets, a changeset says what a consumer must do
differently, and its bump matches the commit subject's own claim — a ! in the subject is a
major. Why a decision was taken is not what a changeset is for.
tailwind-variants is imported directly, never through a local shim. A lib/tv.ts re-export
existed, was bypassed by most of its callers, and was deleted. If a shared twMergeConfig is ever
needed, that is the moment to reintroduce one seam — with a lint rule to enforce it.