Kanzo UI
The design of Kanzo UI

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.

A measurement overrules the reference; nothing else does

The reference governs the surface; a measurement governs the reference; nothing else governs either.

Conventions owns that sentence and the five things it does not say on its own. This page is the case law under it: what it has actually decided, and what would decide each of those the other way.

The reason it is falsifiability: a divergence has to be checkable by someone who was not in the argument, against a source outside this repository, today and in a year. A ratio is. A principle, however correctly applied, is not.

The collision this settles. An export audit deleted the one-line context aliases under the second-call-site rule — renaming somebody else's export is not an API. Shark's registry/react/components/ ships nearly all of the same names, as the same one-line renames. Both rules were applied correctly, and the evidence was identical on both sides: Shark's aliases have no call site inside Shark either, so matching the reference meant shipping exports the reference itself does not consume. Work stopped and the owner arbitrated, for the reference. What made the house rule lose is not that it is the worse rule — it is that it had a principle where the reference had a registry, and a registry answers to a fetch.

What would reverse it: a divergence that is right and has no number behind it — a defect in the reference with nothing to count against it. The one candidate has since been settled the other way (see Steps below), and had it closed by our stripping the role, this rule would have been too narrow and measurement would have had to widen to admit a named clause of an external standard cited with the case that violates it. Separately and more cheaply: Shark ceasing to be a reference we track, at which point it has no surface left to govern.

No test holds the tie-break itself, and there is nothing to write. Each half is guarded; the ordering between them is a rule about how an argument ends, and the only thing that can fail it is a person.

Held by packages/ui/src/index.test.ts, "tracks Shark's context aliases and parts, in both directions" — the reference half, asserted in both directions off one list; packages/ui/src/simples/button.tsx, buttonVariants, which carries the solid focus ring — the measurement half.

Match the reference; do not invent

Before changing a token, a recipe or a convention that came from Shark UI, read Shark's source:

gh api "repos/sharkui-inc/shark-ui/contents/<path>" --jq '.content' | base64 -d

Docs sites go stale; the repo does not. (vinihvc/shark-ui was the old owner and still redirects, which is why the wrong name went unnoticed for a while. sharkui-inc is the one to write.)

An audit once called the status tokens self-contradictory, and --destructive-foreground was renamed to an invented destructive-emphasis — then Shark turned out to define the identical pair and use the second as a background. Fully reverted. Fixing a defect that is an upstream convention forks the library for nothing.

What would reverse it: a divergence we can measure and Shark cannot answer. That has happened once and is recorded — the solid focus ring, kept against Shark's diluted one on a contrast measurement — and the general form of it is the ordering above.

Held by packages/ui/src/simples/button.tsx, buttonVariants, which carries the solid focus ring and the measurement that justifies the divergence.

A name Shark ships is ours; a name it does not is not

The context aliases and the re-exported Ark parts track Shark UI's registry in both directions. A context alias or a part that Shark's registry/react/components/<name>.tsx exports, we export under that name; one it does not export, we do not. For these names this overrides the second-call-site rule — they ship without a call site of their own — because a consumer arriving from Shark meets the same vocabulary, and that claim is checkable against a source outside this repository. We might need them later is not.

The reversal this records: an export audit deleted every one of these, tombstoned them, and rewrote eleven doc pages to point at Ark's hooks instead. Then the reference was read. Shark ships them, with these names, as the same one-line renames — including the ones no mechanical rule would produce: useResizable is Ark's useSplitterContext, useRating its useRatingGroupContext, useSheet its useDialogContext. Those three are the whole of the non-mechanical set, recountable in packages/ui/src/simples as the aliases whose right-hand side is not the left plus Context, and all three match Shark exactly — which is as close to conclusive as provenance gets.

The conflict with the house rule is on identical evidence, and saying so is the point. These aliases have no call site inside Shark either, so matching the reference here means shipping exports the reference itself does not consume. The rule is not being satisfied by some consumer we overlooked; it is being overruled, on the narrow ground that a shared vocabulary is the thing being bought and a vocabulary with holes is not one.

usePinInput is the exception, and it is a different kind of absence. Shark has no pin-input component at all — it solves that problem with input-otp.tsx, which exports no hook. The name was never the reference's, so parity neither grants it nor refuses it, and it falls back to the house rule. ListboxContext and the three ColorPicker* parts are the plain case instead: Shark's files export the neighbours and not these. The other direction is not empty either — ADDED, in packages/ui/src/shark-parity.divergences.ts, declares twenty names we export that Shark's matching file does not, each with the reason it survives.

The rule is over names, and useTagsInput is why that wording is deliberate. Ours aliased Ark's useTagsInputContext; Shark's aliases Ark's useTagsInput, the machine hook, and ships the context one beside it. So the name matched and the binding did not. Closed by taking Shark's shape for that file whole — the plain name is the machine hook, useTagsInputContext is the context one, and TagsInputRootProvider, the reason the pair has to exist at all, is adopted with them. A second name-versus-binding case would be as invisible to the comparison as this one was.

What would reverse it: Shark dropping them, or the library gaining a reason to define its own context surface rather than re-export Ark's — at which point the names are ours to choose and the reference stops answering the question.

Held by packages/ui/src/index.test.ts, "tracks Shark's context aliases and parts, in both directions", which asserts the presences and the absences from the same list.

A house principle withholds no name the reference ships

Every name Shark UI's registry/react/components/<file>.tsx exports, we export — including a part our own root already renders, and including a tv() recipe. The two house rules that had been withholding names still govern everything the reference is silent about, and nothing it speaks about.

Thirty-three names were withheld under does not export a part its own root already renders — thirty-one parts the component places itself, plus two hooks that came back with them — and four under a class list is not API: alertVariants, badgeVariants, menuContentVariants, toggleVariants. Both arguments are good. Neither is checkable by somebody who was not in the argument, which is the whole of why they lose.

Seven were doubly evidenced, and that is what forced the question. Our own pages documented them as reachable — ClipboardIndicator inside a Usage import block, so that example did not compile; ComboboxClear, ComboboxGroupLabel, PopoverClose and TourClose in anatomy trees; ToastItem in a sentence saying outright that it is exported so a custom Toaster can reuse it; and ScrollAreaScrollbar in a sentence that had already been deleted once rather than made true. Checking the pages against the barrel found six of them and could not choose which side to fix. The reference chose.

The composition audit, which is the knowledge this bought. A name is not the whole of parity; the composition is too, and restoring an export blind would have shipped a defect on purpose. So for every one of the thirty-one whose root renders it, Shark's own file was fetched and read. Our roots and Shark's render the same parts in the same places, without exception. Progress renders <ProgressTrack><ProgressRange /></ProgressTrack> unconditionally and after {children} — and so does Shark's, which exports both anyway. ScrollArea places two scrollbars, Checkbox two indicators, PopoverContent a positioner and a close, Toaster a ToastItem per toast; Shark does each identically. Restoring an export is not permission to change a composition: check what the reference's root renders before assuming a doubled part is ours.

ClipboardIndicator is the one that is not quite either. Shark's ClipboardTrigger renders only what it is given; ours defaults children to a ClipboardIndicator, which the page documents and which a caller overrides simply by passing children. A divergence in the trigger's defaulting, not a doubled part.

What restoring cost that nobody had counted: two hooks that collide with Ark. useCombobox and useTourContext came back with the parts, and both are the useTagsInput shape a second and a third time — a name matching the reference exactly whose binding is not the one the name implies. Neither diverges from Shark; the clash is with Ark. @ark-ui/react exports useCombobox, the machine hook that takes props, and useComboboxContext; ours is the second under the first's name, so a consumer with both packages in scope has two useCombobox with incompatible signatures and nothing to read the difference off. useTourContext is worse for ending in Context while returning something that is not Ark's tour context. Parity over names cannot see any of this, and it takes the count of known name-versus-binding mismatches from one to three. The next will arrive the same way.

What would reverse it: a consumer depending on a recipe's internals in a way that blocks a restyle — reading a variant key, composing the returned class string, or overriding one of its utilities by specificity. That is the honest cost of the recipe half, accepted knowingly rather than argued away: exporting alertVariants freezes our class list as public API, which is a different kind of commitment from exporting a component, because a component promises a shape and a recipe promises the appearance we most want to keep changing. If one turns up, the recipes come back off the surface and the parts stay — the two halves reverse separately.

Held by packages/ui/src/index.test.ts, "exports every part Shark's registry exports, including the ones our own root renders" and "keeps a recipe off the public surface unless the reference or another module ships it"; packages/ui/src/shark-parity.test.ts, "ships every name Shark ships, or declares why not", which is what makes the claim checkable against a source outside this repository.

A part is named by its machine

Where Ark and Shark spell the same part differently, the exported name is Ark's. AccordionItemTrigger, not AccordionTrigger. NumberInputIncrementTrigger, not NumberInputIncrement. PaginationPrevTrigger, not PaginationPrevious. FileUploadItemSizeText, not FileUploadItemSize. Nine parts, one rule.

The name of a part is a fact about the machine underneath it, not a choice about how the surface looks, so it belongs to the reference that owns the machine. A reader who meets AccordionItemTrigger and goes to Ark's documentation finds it under that name; one who meets AccordionTrigger has to guess which part of the machine it wraps before they can look anything up. Shark shortens because it flattens compounds for a copy-paste registry, which is a different problem from the one this library has.

This settles nine names and not the general question. It does not rank Ark against Shark; it says only that a part name is behaviour's to give. The owner was offered the general question and chose the narrow answer, which is the right size — a tie-break invented ahead of the cases it must settle is how a rule ends up claiming more than it can prove.

What would reverse it: a part whose Ark name describes the machine's internals rather than the thing a caller places — a name that is precise and useless. None of the nine is: every one names an element a consumer writes by hand. Also reversed if Ark itself renames a part, in which case the exported name follows Ark rather than freezing, because the reason here is the pointer to the documentation and not the spelling.

Held by packages/ui/src/shark-parity.divergences.ts, whose RENAMED table declares all nine as a pair; packages/ui/src/shark-parity.test.ts, which reads that table and fails. It is the reference-level justification for the base-plus-part rule on Conventions.

Adopt the part the machine ships; decline the one the reference composed

Where Shark exports a name we do not, and neither the reference rule, a measurement nor a house rule settles it, the tie-break is Ark: adopt the name if @ark-ui/react already ships the part underneath, decline it if Shark composed the part itself. Where the machine ships the part, adopting is a thin wrapper over something we already depend on, and declining leaves our compound missing a part the machine offers; where the reference composed it, the name carries a layout choice we did not make.

Adopted — ClipboardValue (as ClipboardValueText, Ark's spelling), FileUploadClearTrigger, FileUploadItemPreviewImage, FileUploadRootProvider, useHighlight, MenuArrow. Ark ships clipboard-value-text, file-upload-clear-trigger, file-upload-item-preview-image, file-upload-root-provider, use-highlight, menu-arrow and menu-arrow-tip.

Declined, no such part — FileUploadTitle, FileUploadDescription, FileUploadHelper, FileUploadDropzoneIcon, TourBody. Each is a bare ark.div in Shark's file under a component's name; Ark's file-upload and tour anatomies have none of them.

Declined, no machine at all — SkeletonCircle, SkeletonText. Skeleton is a shadcn-shaped primitive, an ark.div with a pulse, so there is no part to have provenance.

Declined, the reference composed it — FileUploadList, PaginationItems, PaginationItemLink. The first two are loops over context that fix an arrangement a caller cannot reorder; the third hand-writes an <a href="?page=N">, which is a routing convention rather than a part.

This settles the names the divergences file was holding as undecided and nothing wider. It is not a general rule for adopting from Shark, and it reopens nothing already decided.

What would reverse it: a declined name a consumer or a docs page turns out to want — the shape being a page that has to describe the part in prose because there is nothing to import, or a caller rebuilding one of the twelve by hand. That is a low bar on purpose, because the line here is defensible and not principled.

Held by packages/ui/src/index.test.ts, "adopts the Shark names Ark ships a part for, and declines the ones Shark composed", !SkeletonText — the seven presences and the twelve absences off one list; packages/ui/src/shark-parity.test.ts, "names the divergences nobody has decided yet", which pins what is still open.

A vendored shape is not a defect

Where a simple is a faithful Shark vendoring, its shape answers to Shark, not to a purity audit. Reversing an upstream convention forks us for no accessibility gain.

Four components were flagged as hand-rolled behaviour and all four were exonerated against Shark's registry source: resizable is a thin wrapper over Ark's Splitter with no dragging of its own; tour imports Ark's real machine and adds only Shark's start callback; command is Ark's Combobox inside Ark's Dialog, with Shark's own two presentational leaves; and sidebar hand-rolls its context, its shortcut and its mobile swap because Ark ships no sidebar — Shark's own file does the same.

What would reverse it: a divergence we can measure, which is the same bar as above. A rule violated only by inherited shapes is a rule aimed at the wrong file.

Held by packages/ui/src/simples/resizable.tsx, simples/tour.tsx, simples/command.tsx, packages/ui/src/composites/sidebar.tsx.

"Idiomatic to Ark" is a category error for layout

The layout layer takes its references from the app-shell libraries built on a headless core — shadcn, Mantine, Ant. Ark supplies the authoring idiom (Root, named parts, ark.*) and the Splitter machine, and nothing else. data-slot is not Ark's — its dist emits none; that convention comes from Shark and we hand-write it.

Ark is a behaviour library and ships no layout: the one layout-adjacent primitive is Splitter, which is our Resizable. There is nothing upstream to match, so make it Ark-native asks for conformance to an empty set.

What would reverse it: Ark shipping a layout family. Its component directory is the check.

Held by packages/ui/src/simples/resizable.tsx, the one place the Splitter is used.

AI Elements is a source, not a reference

@kanzo-tech/ai takes its shapes and its state vocabulary from Vercel's AI Elements and its names from this house. There is no second parity snapshot and no second divergences file; Shark UI remains the only reference, and it governs @kanzo-tech/ui alone. A reference is a thing you can be held to, and holding a package to a registry we cannot install would buy a check nobody can run.

Taken whole: the rule that a finished call opens by default. The transcript's pin-to-bottom behaviour, which is the only part of a conversation that is not markup. Reasoning opening while it streams and closing when it stops. And, since Chat draws the AI SDK's message parts directly, the SDK's own tool states rather than a vocabulary of ours: four tones, and the label says which of the seven it is.

Taken and re-pointed: their StackTrace became Diagnostic in @kanzo-tech/ui, because a severity, a message and a list of source positions is a SHACL violation and an LSP diagnostic, and neither is AI. Their SchemaDisplay loses its REST half.

The parts are the surface, beside the door. Chat is the conversation wired, and every part it draws with is exported, because a host's guided chat — keasy's assistant, whose turns are the host's — draws the same parts in another arrangement. Refusing them once made every such host hand-roll a transcript, a decision and a busy line; a part with a second arrangement is a part. A tool's result is still JSON only by default, and Chat's tools map draws it as what it is.

What is admitted is what a host draws. Each of AI Elements' components, and where it stands (audited 2026-10-13 against vercel/ai-elements' packages/elements/src):

AI ElementsHereHow ours differs
ConversationConversation, ConversationContent, ConversationScrollButtonThe pin is ours, fifty lines rather than use-stick-to-bottom; role="log". Their ConversationEmptyState is Chat's empty, their ConversationDownload not admitted
MessageMessage, MessageList, MessageContentrole declared once and read by the parts; a list with list semantics. MessageResponse is Chat's markdown, internal; MessageAction, MessageActions, MessageAvatar, MessageToolbar and MessageBranch not admitted — the avatar and the actions row shipped and left with no product drawing them
PromptInputPromptInput, PromptInputHeader, PromptInputTextarea, PromptInputToolbar, PromptInputSubmitOn InputGroup; the submit is Send, Stop or Retry by ChatStatus. The header is their row above the field, holding the host's pills rather than attachment cards. Their attachments, model select, action menu and speech not admitted
ReasoningReasoning, ReasoningTrigger, ReasoningContentA spinner, not a shimmer; the reader's own toggle ends the automatic opening
ToolTool, ToolHeader, ToolContent, ToolInput, ToolOutputBound to the part; Stopped, a state the SDK lacks
Suggestion, Suggestions@kanzo-tech/ui's Suggestion, SuggestionsWraps rather than scrolls; a rationale in the tooltip; pending pills in skeleton while more arrive. One strip for a model's offers and anyone else's: Chat and Assist draw theirs with it
Question@kanzo-tech/ui's Question and its partsOne part, the one a Questionnaire asks in a row: a FieldSet whose choices are cards on Ark's radio or checkbox group, so the arrow keys work. Their QuestionInput is ours, for a typed answer; QuestionSubmit not admitted: a pick is the answer
ShimmerWorkingA spinner beside the words, as every busy surface here, and a Progress under them given a value
Loader@kanzo-tech/ui's SpinnerAI Elements retired it for shadcn's Spinner
StackTrace@kanzo-tech/ui's DiagnosticA SHACL violation and an LSP diagnostic are the same shape, and neither is AI
SchemaDisplay, FileTree@kanzo-tech/ui's TreeView, DataListNo schema display
CodeBlock, Snippet@kanzo-tech/ui's CodeEditor, Prose
CheckpointCheckpoint, CheckpointIcon, CheckpointTriggerAn <li> of the transcript, its rule hidden as paint; no tooltip on the trigger. What restoring does is the host's onClick
ContextModelBadgeTheir shape — a small trigger with the detail behind it — for a model in the page as it loads rather than a context window's usage; a popover, not a hover card, because a failure carries a retry
ChainOfThought, ChainOfThoughtStepChainOfThought, ChainOfThoughtStepAn <ol> of the host's steps rather than the model's reasoning; their icon, label, description, status and body kept, waiting added to their three statuses, action trailing the row. The status is also said in words. Their ChainOfThoughtHeader, Content, SearchResults, SearchResult and Image not admitted
Sources, SourcesTrigger, SourcesContent, SourceSources, SourcesTrigger, SourcesContent, SourceAbove the thread rather than under an answer; the trigger's count is a pill and Sources a translation; a Source is a pill whose words are its children, not a link with a title

Not admitted, and what would admit each:

  • Plan, Task, Queue, Agent — read 2026-10-14, and each left for what it is. Plan is a card (title, description, a collapsible body) around content of the host's own: it has no step, so it would wrap ChainOfThought, not replace it. Task is a collapsible group of one search's lines and file chips, with no status, and would collide with this library's Tasks, which are an artefact a small model fills. Queue is a todo list of two states, pending and completed, with a hover-only row action; no state for under way or waiting, and a list that hides what the reader must see. Agent is an agent's configuration — name, model, instructions, tools, output schema. A host that wants a collapsible card around its steps (Plan), or a todo list whose rows are two-state (Queue), or shows the reader what an agent is configured with (Agent).
  • Confirmation — a tool call that waits for the person's approval. A host whose agent declares a tool with needsApproval; Tool already reads Awaiting approval, and the actions would join it.
  • Context's own content, the context window's usage. A host that shows the reader the budget Asking within a context spends; ModelBadge is where it would go.
  • InlineCitation — a citation marked in an answer's text. A host whose answers rest on documents rather than on the page's data, whose evidence is the tile.
  • MessageBranch — alternative answers. A host that regenerates an answer and keeps both, or keeps the turns a restore cut as a branch.
  • Attachments, ModelSelector, Image — files sent with a question, a picker of models, an image generated. A host that sends files to a model that reads them, or offers more than one model. PromptInputHeader is where their cards would go. Attachments was read again for the thread's details and left: its three variants draw a file with a media preview and a remove, and a source here is a connection the host names and the reader neither previews nor removes from the block.
  • Artifact, WebPreview, Sandbox, JSXPreview, Terminal, TestResults, Commit, EnvironmentVariables, PackageInfo — a coding agent's surfaces. A host that is one.
  • Canvas, Node, Edge, Connection, Controls, Panel, Toolbar — a workflow diagram. The graph is @kanzo-tech/graph's; a program summary turned out to be a list of signatures.
  • The Voice family — Persona, SpeechInput, Transcription, MicSelector, VoiceSelector, AudioPlayer. A host that talks.
  • OpenIn — refused rather than waiting: its items are other companies' products by name.

A point to go back to and the sources a message carries were admitted together, for keasy. Its guided assistant draws a preview per answer and lets the reader go back to one, and asks each question over sources the reader picks in the composer. Checkpoint is AI Elements' three parts and their look; it restores nothing, because what going back means — keasy's cuts the thread, as AI Elements' example does; another host may branch — is a decision about the host's data, not about the row. PromptInputHeader is their header region, the composer's block-start band beside the toolbar's block-end, and holds whatever the host puts there; the checkable list a pill opens is @kanzo-tech/ui's MenuCheckboxItem, which Ark's Menu.CheckboxItem already gave, so nothing new was needed in ui. Chat's context — one read-only pill, what the next question is asked over — stays beside Send: in the toolbar it shares the controls' row, and above the field it would cost a side panel a line for one pill.

What would reverse them: a host whose restore needs more than a press — a confirmation, a diff of what goes — drawn the same way twice, which would make that part of Checkpoint; and a second host drawing Chat's context as a row of pills, or the reader acting on it, which moves it into the header and leaves the toolbar to the controls.

Held by packages/ai/src/checkpoint.test.tsx, "restores nothing itself: the trigger is a button that hands the press to the host"; packages/ai/src/prompt-input.test.tsx, "carries the host's pills above the field, each opening what it holds"; packages/ai/src/chat.test.tsx, "shows what the next question is asked over in the composer, beside Send".

The steps of the work and what the thread rests on were admitted for keasy's assistant thread. The thread shows what I am doing — rows done, under way, waiting on the reader and still to come, future ones visible, each a plain line with a mark, a detail and sometimes one action — and, pinned above the transcript, what the reader said the data is about with the sources they picked. Neither needed a vocabulary of our own, and neither Tool nor Working could carry them: Tool is a card bound to one AI SDK part with a collapsed body, and Working is one busy line.

Steps are ChainOfThoughtStep. Of AI Elements' candidates it is the only row with a status, an icon, a label, a detail and a body at once; Queue has two statuses, Plan no rows and Task a group of lines. It departs in four places, each for a reason: the status waiting, because a step that stops for the person is neither active (nothing is running) nor pending (it has begun); action, a prop, because the step's verb trails the row where their children fall under it; the status said in words and aria-current, because a glyph and a colour tell a screen reader nothing; and the ink of pending, the same as a done step's, because their muted-foreground/50 fails the contrast a label owes and the hollow mark already says it. It is the host's steps and not the model's reasoning, which is Reasoning. What would reverse it: a host whose steps nest or fold (their ChainOfThoughtHeader and Content return with it), or a second status a step passes through that is neither of the four, which belongs in the type rather than in a host's icon.

The details are Sources. Of the three the owner named, Context is a context window's usage and is already ModelBadge's shape, Attachments is a file's card, and Sources is a collapsible with a count over the things the conversation reads — the block. It departs in the pill (a source is a connection the host names, not a link with a title; asChild makes one a link) and in the place (above the thread, not under an answer); the reader's words are the host's child of SourcesContent, since a paragraph needs no part. What would reverse it: a second host whose details are more than words and pills, which would make the words a part; or sources that are always links, which would give Source an href.

Held by packages/ai/src/chain-of-thought.test.tsx, "is an ordered list with every step visible, the future ones too" and "says each status in words, since its mark is paint, and marks the present step"; packages/ai/src/sources.test.tsx, "is a trigger naming the block and counting its sources, closed until pressed".

What would reverse it: a shipped npm package we can resolve types from, which is what would make parity checkable rather than aspirational. Until then the table above is re-derived by reading their src, and a row is added when a host draws a part that is not in it.

Held by packages/ui/src/shark-parity.test.ts, "ships every name Shark ships, or declares why not"; packages/ui/src/shark-surface.json.

Steps claims a tab role that nothing keyboard-implements

linear keeps the machine's default of false, and the role stays — it is not to be stripped on our side. Zag emits role="tab" together with the aria-selected / aria-controls / id wiring that makes the relationship legible, so removing the role alone leaves aria-selected on a non-widget role — a second defect on top of the first — and removing all of it means hand-rolling the ARIA relationship, which the accessibility rule forbids outright.

The two halves, because only one of them is upstream's. Verified against @zag-js/steps@1.41.2: getListProps emits role="tablist", getTriggerProps emits role="tab", and the package contains no onKeyDown at all — no arrow keys, no Home/End. That half is genuinely upstream's.

The half we own is the linear prop. tabIndex: !prop("linear") || itemState.current ? 0 : -1 means the default of linear: false gives every trigger a tab stop — precisely the each item independently tabbable, no roving focus shape our own conventions name as worse than no role at all.

Why linear: true was rejected, and it is not the reason this was opened with. It looked like a product trade — one tab stop, at the cost of jumping ahead to an incomplete step. Then steps.connect.js turned out to gate a second thing on the same prop: under linear the trigger's click handler returns early. So a non-current trigger goes to tabIndex: -1, gets no arrow keys because the package has none, and stops responding to a pointer — unreachable by either route while still announcing role="tab". That is a larger accessibility defect than the default, not a smaller one, and it closed the only alternative that existed.

What remains is arrow keys and Home/End do nothing, which no default can fix. The Steps page says which keys actually work — Tab and Enter, and nothing the role advertises — because a page that omits that lets a reader assume the contract holds. Filing upstream is not planned, and the check below is why that costs nothing: what a report would buy us is knowing when it is fixed, and the test tells us that on the next install.

What would reverse it: Zag shipping key handling, which closes it; or a decision that Steps is linear, which closes the worse half without touching upstream.

One correction found while measuring: the arrow keys and Home/End are written as object-method shorthand in Zag's keymap, so a scan for quoted key names reports @zag-js/tabs as handling nothing. The claim was right; the obvious way to check it is not.

Held by packages/ui/src/simples/steps.tsx; packages/ui/src/simples/steps.test.ts, which reads the shipped bundle and fails when @zag-js/steps grows a key handler — the reversing condition, measured instead of remembered.

On this page