Kanzo UI
The design of Kanzo UI

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

A measurement a component needs in order to work may not be keyed on data-slot. 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)]. The density knob is untouched and still --size-field. Appearance keyed on data-slot is fine and stays — losing a colour on a rename is the caller asking for a different look, which is what the seam is for.

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.

Held by packages/ui/src/no-measurement-on-a-renameable-slot.test.ts, "is written nowhere"; packages/ui/src/simples/button.tsx, the h-(--size) on base.

A type and a component may not share a name across two barrels

@kanzo-tech/ai's Suggestion type is Candidate. @kanzo-tech/ui's Suggestion component keeps the name, because it is the one Shark ships. No alias, no deprecation — nothing has been published.

The two were in scope in the same file and one of them could not be written. packages/ai/src/suggest.tsx draws the component while typing its data with the type, and it was importing its own package's type under an alias to do it. An import that has to rename a symbol to be usable is the collision announcing itself, and the alias was the workaround rather than the answer. A consumer hits the same wall the first time they write both imports, with no file of ours to look at for the local name that got chosen here. 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.

The alias was hiding a better name. import type { Suggestion as Candidate } from "./types.js" sat three lines under import { …, Suggestion, Suggestions } from "@kanzo-tech/ui". It compiled, it was correct, and it had already chosen the word the rest of that file uses. The rename does not invent anything; it promotes what the code was already saying in private. The type appeared in seven source files, seven documentation files and two showcases — none of it difficult, and all of it invisible until somebody writes both imports at once.

What would reverse it: Shark shipping a Suggestion type of its own, which would make Candidate 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/types.ts, Candidate; packages/ai/src/suggest.tsx, the import that no longer aliases.

A hook takes the name of the request it makes

The ghost-text hook is useInlineCompletion, and its surface types are InlineCompletion, InlineCompletionRequest, InlineCompletionTrigger and UseInlineCompletionOptions. We had taken the semantics of textDocument/inlineCompletion and the identifier of textDocument/completion.

LSP 3.18 ships the two as separate requests and the split is not cosmetic: the first returns a list the editor presents and the user picks from, the second returns text drawn where the caret is and accepted whole. Ours is the second, and the hook's own doc comments already said so — they cite InlineCompletionTriggerKind for the trigger union, filterText for the rule that keeps an offer alive while what has been typed agrees with it, and Monaco's inlineSuggest.mode: "prefix" for the same rule under another name. Three citations to the inline request, under the identifier of the other one.

The AI SDK's useCompletion is a different hook with the same old name — it owns an HTTP endpoint and the field's value, ours owns neither — and that collision is real, but it is not the argument. A name is wrong because it names the wrong thing, not because somebody else took it. The collision note belongs where a consumer is choosing between two imports, not on a page that would then be teaching the wrong subject.

Complete did not move, and that is the case that does not fit. By the same argument it should be InlineComplete. It collides with nothing — neither the AI SDK nor Shark ships a Complete — and the rename is three times the diff for zero collisions solved and no clarity gained: a compound named Complete over an Input, drawing a ghost, is not mistakable for a picker.

Complete / useInlineCompletion is deliberately not a pair, and the old spelling was not one either. The house pattern is X / useX where the hook reads the compound's context — useDataTableContext beside DataTable. This hook reads no context and is usable with any input; Complete is one of its callers. The old Complete / useCompletion looked like the house pair and never was, which is its own reason to break the resemblance.

What would reverse it: LSP collapsing the two requests into one, or a second hook here whose semantics are the plain request's — a list of candidates the caller picks from — which would then own the short name.

Held by packages/ai/src/index.ts, useInlineCompletion; packages/ui/src/index.test.ts, "drops components superseded by composition or a merge", !useInlineCompletion; packages/ui/src/documented-exports.test.ts, "documents no symbol the library does not export".

On this page