Names and slots
Who owns `data-slot`, what may never be keyed on it, and the two collisions that decided a rename.
Conventions has the spelling rules. This page has the four arguments that produced them, and what would say each is wrong.
A primitive owns its data-slot
data-slot is written after the rest spread, on every part. That ordering is the rule and the
whole of what the guard enforces: a caller cannot erase a slot by passing one. Re-slotting is a
declared slot?: string prop, spelled data-slot={slot ?? "<component>-<part>"}, and that is the
only way to do it. Most parts carry it; a part that writes a literal instead is one a caller cannot
rename, and nothing checks the spelling.
data-slot is not decoration and not a default: our own recipes select on it, so a caller who
happens to pass one silently deletes styling the component depends on — no error, no visible symptom,
and nothing to grep for. An escape hatch a caller can unhook is not one. But renaming a part is a
real need, and it was being served by the same accident that caused the erasure, so the two had to be
separated before either could be enforced.
This rule was refined by its own reversing condition, which is the format working. The first
version said the case that would reopen it was a consumer needing to re-slot a primitive as distinct
from erasing its slot, and that such a case would want an explicit affordance — "a second attribute,
or a slot prop the component composes with its own" — rather than the accident of spread order. It
then said none had been asked for. That was wrong within the day, and wrong in the most useful way:
moving the attribute past the spread broke every thin rename in the library at once, because
overriding the wrapped primitive's slot is the rename mechanism. Because the condition had
described the counter-example closely enough to recognise on sight, the rule was refined rather than
re-argued. A reversing condition is worth writing even when — especially when — you believe nothing
will meet it.
One trade, taken knowingly. slot is a real DOM attribute — shadow-DOM slotting — and we now
consume it rather than forward it. Nothing in this library or in docs/ uses shadow DOM, and the prop
needed no new type anywhere because React's HTMLAttributes already declares it, which is what made
the conversion free. A consumer who needs to slot one of our elements into a shadow root is the case
that would have to be answered, and none exists.
The evidence that made the ordering a house rule rather than a three-site bugfix: the great majority of
sites wrote the attribute before the spread, and one file had already discovered the problem and
reversed itself in place — packages/ui/src/simples/combobox.tsx, ComboboxTrigger, which writes its
slot past the spread and hands the same value down to the Button it renders under asChild, because
composed inside an InputGroupButton the merge injected the wrapper's slot and its own matched
nothing. A rule one file learns the hard way and the rest of the library contradicts is a house-style
decision, not a defect. ComboboxTrigger is also the exception to the paragraph above: its slot is a
literal, so it is the one trigger a caller cannot rename.
What would reverse it: a part whose slot has to vary on something the call site cannot name, which
a prop cannot express. The conversion tested this and it held — under asChild, a parent can no longer
name an element it does not render, because the child now writes its own slot after its own spread and
always wins. Three compositions hit it, and all three were expressible: the slot moves down to the
child as slot, which is the more honest shape anyway.
Held by packages/ui/src/data-slot.test.tsx, which is what can now tell a rename from an erasure;
packages/ui/src/simples/alert-dialog.tsx, the thin renames whose own recipes select the renamed
values.
What identifies a part is not what a caller may change
Nothing a component needs in order to be itself may be keyed on data-slot — not its size and
not its look. Control heights live on each recipe's base as h-(--size), where the size variants
only move a custom property: [--size:calc(var(--size-field)*7)]. Both are written once, in
packages/ui/src/lib/control-size.ts, and every recipe imports them — Controls
has the rule and the exceptions. Button's fill, ink, edge, lift,
grain and both state washes live there too, and the variants only assign --btn-bg, --btn-fg and
--btn-bd. The density knob is untouched and still --size-field.
data-slot is two things at once and only one of them is stable. It is the seam a call site styles
against, and this house makes it renameable on purpose: every part takes slot?: string. A
measurement hung off it turns that seam into a load-bearing wall — exercise the rename and the control
silently stops being the size it declares, with no type error, no failing test and nothing to grep for.
It had already happened, to a control that renames a Button, and it landed under the pressable floor
this library adopted.
The measurements. Taken in a live page, one document, three controls off the same recipe:
data-slot="button" at sm rendered 28 px and at md 32 px, while conversation-scroll-button at
icon-sm rendered 16×16 where its size asks for 28. WCAG 2.5.8 states 24×24 CSS pixels, which is the
floor pressable-floor.test.ts adopted. Seven further icon-only renames sit on the same fault —
calendar's previous and next, pagination's previous, next and item, and file upload's item delete — all
found by grep after the first was measured, and none of them measured in a browser.
The placement is daisyUI's: their base rule declares the height and the size modifier only sets the
variable. We had already taken the density knob from them without taking the placement that makes it
safe. What the old placement was right about survives the move: control height is --size-field and
padding is Tailwind's --spacing, because a tenant asking for compact controls is not asking for
tighter text. That split is kept exactly. The old comment's own reasoning — "data-size is already on
the element, so this needs no new attribute" — was true about data-size and quietly untrue about the
data-slot beside it in the same selector.
What would reverse it: a measurement that genuinely has to vary per part rather than per size,
which a custom property on the base cannot express. The three families here did not — button, input
and native-select all used the same formula per size (sm ×7, md ×8, lg ×9), so the slot in
those fifteen selectors was carrying no information at all, only the fault.
Colour is not keyed on a slot either
"Losing a colour on a rename is the caller asking for a different look" is true about a caller
and not about us, and the distinction is the whole rule.
Thirty-one call sites in this library hand Button a slot, and not one was asking
for a different look: they were asking for a different hook, so a consumer could target the part.
[data-slot="button"] was the only rule that read --btn-bg, --btn-fg and --btn-bd, so all
thirty-one shipped with no fill, no ink and no edge while their variants went on assigning all
three. AlertDialogAction and AlertDialogCancel are the two that were caught: measured in a live
document, both footer buttons of the library's own alert dialog reported their data-variant
correctly and background-color: rgba(0, 0, 0, 0).
05f3a0a had the argument in its hands and stopped one attribute short. The height it moved and the
colour it left were in the same selector, and the carve-out was written to justify the half that
was not moved rather than measured. That is the failure worth naming: a rule that explains why the
remaining case is fine, written in the same commit that fixed the case beside it.
The cost is real and is the one the stylesheet predicted. A color-mix() spelled as a Tailwind
arbitrary property — brackets, underscores for spaces, no line breaks — is materially worse to read
than the CSS it replaces, and buttonVariants' base now carries five of them. That price was always
the argument for the old placement; what nobody weighed it against was a control that does not
paint.
What would reverse it: a part whose look genuinely has to vary on the slot rather than on a
variant — a rule that must apply to one renaming of a component and not to another. None of the
thirty-one is that: every one of them takes a variant and gets it right. A consumer's own
[data-slot=…] rules are untouched by this and always were; the ban is on our stylesheet
painting our parts through a name the consumer owns.
Held by packages/ui/src/no-measurement-on-a-renameable-slot.test.ts, "is written nowhere";
packages/ui/src/no-paint-on-a-renameable-slot.test.ts, "is never read only from under a renameable slot";
packages/ui/src/lib/control-size.ts, controlHeight, the h-(--size) each control recipe spreads
into its base.
A type and a component may not share a name across two barrels
What @kanzo-tech/ai offers a field is a Proposal. @kanzo-tech/ui's Suggestion component
keeps its name, because it is the one Shark ships, and the chips Assist draws are that component.
The rule was learned on the type's previous name. It had been Suggestion too, and the file that drew
the chips was importing its own package's type under an alias to do it —
import type { Suggestion as Candidate } from "./types.js", three lines under
import { …, Suggestion, Suggestions } from "@kanzo-tech/ui". An import that has to rename a symbol to
be usable is the collision announcing itself, and a consumer hits the same wall the first time they
write both imports. Asked which of two colliding names the reference governs: Shark ships Suggestion
as a component, so that half is not ours to move, and the type is. It became Candidate, which is
what the alias had already been saying in private, and then Proposal when the ghost and the chips
turned out to be one concept — below.
What would reverse it: Shark shipping a Suggestion type of its own, which would make ours a
divergence from the reference rather than a way of keeping it — or the component leaving ui, at
which point the collision is gone and the shorter name is free again.
Held by packages/ai/src/task/index.ts, Proposal; packages/ai/src/chat.tsx, the import of
Suggestions that no longer aliases.
A name takes the request it models
The thing a field is offered is a Proposal, and it is LSP 3.18's InlineCompletionItem as a field
needs it: its value, and the target it lands at — an insert at the caret, or the range it
replaces. It is the Task's Proposal since one name, one meaning:
the field's had been { text, range }. We had once taken the semantics of
textDocument/inlineCompletion and the identifier of textDocument/completion, and the rename
that fixed it — useCompletion to useInlineCompletion — is what led here.
LSP ships the two as separate requests: the first returns a list the editor presents and the user
picks from, the second returns text drawn at the caret and accepted whole. Reading the second one
properly is what collapsed our two components. An InlineCompletionItem carries a range, and a
range is enough to say both things we had built twice: an insert at the caret is a continuation,
drawn as ghost text; a range over the whole value is a replacement, drawn as a chip. So Complete and
Suggest were one item under two presentations, and the presentation is the control's to choose — a
Textarea can draw ghost text, an Input cannot. One name for the item, one component for the field,
Assist, and the engine behind it is internal.
The AI SDK's useCompletion is a different hook with the same old name — it owns an HTTP endpoint and
the field's value — and that collision was real, but it was not the argument. A name is wrong
because it names the wrong thing, not because somebody else took it.
What would reverse it: a field whose proposals need something an InlineCompletionItem cannot
carry — a command to run on accept, a snippet with tab stops — at which point LSP's own extension of
the item is the place to look first.
Held by packages/ai/src/task/index.ts, Proposal; packages/ui/src/index.test.ts, "drops
components superseded by composition or a merge", !useInlineCompletion.
One name, one meaning, in every barrel
A name the library exports means one thing across every package and entry, and never shadows a name TypeScript already gives. The rule above settled one collision; four more were found by a review of every export on 13 October 2026, and each was settled the same way — the reference's name stays, ours moves:
| Name | Two meanings | Became |
|---|---|---|
Table | @kanzo-tech/ui's component; TanStack's table instance, passed through by /table | /table stopped passing TanStack's through; a host that names the instance imports the type from TanStack, which it installed to use /table at all |
Selection | Mosaic's class; @kanzo-tech/graph's picked vertices | graph's is VertexSelection, with VertexSelectionSource |
Problem | @kanzo-tech/ui's component; @kanzo-tech/theme's section-validation record | theme's is SectionProblem |
Pick | TypeScript's Pick<T, K>; graph's pick record | graph's is VertexPick |
Proposal | @kanzo-tech/ai's field proposal; @kanzo-tech/ai/task's proposal | one Proposal, the Task's (Tasks) |
Each had already announced itself the way Suggestion had: graph imported Mosaic's class as
Selection as Crossfilter to get past its own, and any file of a host that imported graph's Pick
lost TypeScript's. Those aliases are gone with the collisions. The last row also reverses the start
of the first section of this page: the field's proposal and the Task's are one idea — a value, where
it lands, and what backs it — and the Task's Target says where it lands as an LSP edit, which is
what range said for a field.
The guard written for this rule found four more the review had not: Finding and Diagnostic are
each a @kanzo-tech/ui part's name and a Task oracle's type on /task, Link is the root's styled
anchor and @kanzo-tech/navigation/next's guarded next/link, and Tool is the graph's canvas tool,
@kanzo-tech/ai's tool-call part and the AI SDK's type @kanzo-tech/llm re-exports. Which side of
each moves is a decision, so the guard pins the four as unsettled, and the list may only shrink.
What would reverse it: a reference that ships one of the moved names with our meaning — then the name goes back, and the other meaning moves.
Held by packages/ui/src/one-meaning.test.ts, "gives each name one meaning across every
entry, and shadows none of the language's".
One prop translates: translations
Every component that writes words a reader sees takes them through one prop, translations, a
partial record of its strings, in English by default. There were five spellings, decided 13
October 2026 to be one: translations (LanguagePicker, Problem, all of @kanzo-tech/ai), copy
as a record (Preferences' parts and the theme cards), labels (Findings' parts), flat string
props (MadeWith's prefix, infix and loveLabel; PreferencesPanel's title and hint), and
nothing at all. Admission
had already named translations the one way to pass words; the other four were the same idea under
other names, and a host translating a page had to learn each.
The name is the reference's. Ark's machines take their own words as translations — a popover's
closeTriggerLabel, a pagination's labels — so a part over a machine that draws none of the
machine's words takes translations in its place (FindingsRoot over Popover does), and a host
writes one prop name whichever layer a word comes from. A compound takes it once on its root and
every part inside reads it; a part's own translations win for that part (PreferencesRoot,
FindingsRoot).
A part that writes a sentence from pieces takes the pieces' pattern as a translations entry with
placeholders — a function of the values — never a sentence assembled in English. A part that
hard-codes English gains translations when it is next touched, in the same change, since a reader
in another language is already a caller: the analytics parts, the table's pagination and most of
graph's parts are still the case, about 160 strings, and none was edited for its own sake.
Problem's describe(code, data) is not a translation. It is the host's wording of a failure it
knows, resolved per failure from its data, and it sits beside Problem's translations, which are
the same whatever failed.
What would reverse it: a host that translates through a provider — an i18n library's context —
and cannot pass a prop per component. Then a provider is the second way, and it supplies the same
translations records, keyed by the component.
Held by packages/ui/src/composites/Preferences.test.tsx, "translates every part through the root,
and a part's own translations win for that part"; packages/ui/src/composites/findings.test.tsx,
"says its severity in the host's words"; packages/ui/src/composites/problem.tsx, describe.
The references, and who wins
The case law under one sentence — Ark owns behaviour, Shark owns surface, AI Elements is a source and not a reference, and a measurement overrules all of them.
The colour document
Why a theme is a flat block of CSS with one mode, how a tint is written, what a section is, and what each would take to reverse.