Skip to content

Research 019 — Design-system components ​

Status: complete All six [NEEDS VERIFICATION] markers in spec.md have a verdict below. V3's refutation of the R8 Tooltip a11y claim has been folded back into the spec (R8/P4 #2/SC2 now require an accessible name on the trigger; the popup is visual-only), so every marker is resolved.

Verified against. @base-ui/react 1.6.0 — the installed package (package.json:3 "version": "1.6.0"; resolved under the pnpm store, import specifier @base-ui/react), read directly, not from web docs. Compiler facts verified against apps/web/vite.config.ts (React Compiler via @rolldown/plugin-babel + reactCompilerPreset({ panicThreshold: 'all_errors' })) and a discarded build spike. Codebase facts against the worktree at branch 019-design-system-components. Date: 2026-08-17. Package paths below are relative to @base-ui/react/.

V1 — Does Base UI 1.6.0 give a Field/Input primitive with automatic label association and aria-describedby error wiring? (spec R6) ​

Question. R6 wants a closed Input({label, error}) where the label is associated with the control and the error is exposed to assistive tech — without hand-rolling id/aria-*. That needs Base UI's Field to own the association.

Verdict. Confirmed. R6 holds; the closed Input maps label→Field.Label, error→ Field.Root invalid + Field.Error, and gets association/aria-describedby/aria-invalid for free.

Evidence.

  • Parts exist: field/index.parts.d.ts exports Field.{Root, Label, Control, Error, Description} (+ Validity, Item). Field.Control renders <input> (field/control/FieldControl.d.ts:10-12); a standalone Input also exists (input/index.d.ts → export { Input }; input/Input.d.ts:6-7: "A native input element that automatically works with Field").
  • Label association is automatic: field/label/FieldLabel.d.ts:6 "automatically associated with the field control"; the control gets aria-labelledby (field/control/FieldControl.js:107).
  • Error → aria-describedby: internals/labelable-provider/LabelableProvider.js:53 composes registered message ids into aria-describedby; Field.Error registers there.
  • Invalid: field/root/useFieldValidation.js:273 sets aria-invalid: true. External error control via FieldRootProps.invalid?: boolean (field/root/FieldRoot.d.ts) and Field.Error match={true} (field/error/FieldError.d.ts) — the seam for a plain error: string prop, no validate pipeline.

V2 — Does Base UI 1.6.0 Dialog provide controlled open/close, focus trap, Escape, backdrop, and title/description a11y? (spec R7) ​

Question. R7 wants a closed Dialog({open, onOpenChange, title, description, children}) that is modal and accessible by construction.

Verdict. Confirmed. All of it is built in and on by default.

Evidence.

  • Parts: dialog/index.parts.d.ts exports Dialog.{Root, Trigger, Portal, Backdrop, Popup, Title, Description, Close}.
  • Controlled: DialogRootProps has open?: boolean, onOpenChange?: (open, eventDetails) => void (dialog/root/DialogRoot.d.ts). The callback's 2nd arg is a details object — the closed component forwards only the boolean.
  • Modal behaviour: modal?: boolean | 'trap-focus' default true — "focus is trapped, page scroll is locked, pointer interactions outside are disabled" (DialogRoot.d.ts). Escape is a built-in close reason (REASONS.escapeKey); outside-press dismissal is on by default (disablePointerDismissal? default false).
  • Title/Description auto-label the popup: aria-labelledby/aria-describedby/role applied at dialog/popup/DialogPopup.js:88-90.

Caveat. With modal on, a Dialog.Close must be rendered inside Dialog.Popup so touch screen-reader users can dismiss, and Dialog.Portal is needed to mount the popup/backdrop at the document root (F3). Both are plan-level construction details, not blockers.

V3 — Does Base UI 1.6.0 Tooltip open on focus as well as hover, and associate its popup with the trigger for assistive tech? (spec R8) ​

Question. R8 asserts two things: (a) the tooltip opens on keyboard focus as well as hover, and (b) "the popup is associated with the trigger for assistive tech."

Verdict. Partial — (a) Confirmed, (b) Refuted. Opens on focus: yes. Auto-association: no such wiring exists. This refutes the R8 clause and its downstream criteria (P4 #2, SC2) — see Refuted claims.

Evidence.

  • Opens on focus and hover: the trigger wires Floating UI's useFocus alongside hover (tooltip/trigger/TooltipTrigger.js:178,155); the Root's change reasons include both triggerFocus and triggerHover (tooltip/root/TooltipRoot.d.ts).
  • No auto aria association: a full grep of tooltip/ finds no role="tooltip" and no aria-describedby; the bundled floating-ui has no useRole. The pinned docs (docs/react/components/tooltip.md:390-391) state it plainly: "Tooltips alone are not accessible to touch or screen reader users… The tooltip's trigger must have an aria-label attribute that closely matches the tooltip's content." Examples put aria-label on Tooltip.Trigger and aria-hidden on the visual.
  • Parts/positioning (for completeness): tooltip/index.parts.d.ts exports Tooltip.{Provider, Root, Trigger, Portal, Positioner, Popup, Arrow}; side/sideOffset live on Tooltip.Positioner (tooltip/positioner/TooltipPositioner.d.ts, default side: 'top').

V4 — Can a Panda slot-recipe class attach to a Base UI part via className without being stripped? (spec R3) ​

Question. The whole closed-component design needs Panda's generated class strings to land on Base UI parts' DOM elements.

Verdict. Confirmed. Every part accepts className (plain string or (state) => string), plus a render prop and style; a plain Panda class is merged onto the element, not swallowed.

Evidence.

  • internals/types.d.ts BaseUIComponentProps: className?: string | ((state) => string | undefined), render?: ReactElement | ComponentRenderFn, style?: …. Every part extends this.
  • utils/resolveClassName.js returns a string className as-is; internals/useRenderElement.js:54,82-83 resolves it and mergeClassNames(outProps.className, className) onto the output props. So Panda classes apply (merged with any internal class).

Caveat. State-conditional styling (open/invalid/side) uses the (state) => string form; the base case is a plain string. See F1 on Base UI shipping no stylesheet.

Question. R10 adds a /ui route with no special handling and a *Page component.

Verdict. Confirmed. A child route added to main.tsx renders inside the App shell with no extra work; the page just needs the Page suffix.

Evidence.

  • apps/web/src/App.tsx:43-57 — App is the layout route: <main> wraps <Outlet/> in QueryBoundary + Suspense. Any child route renders inside those boundaries; a gallery page fetches no server data, so they are inert around it.
  • apps/web/src/main.tsx:15-19 — children: [...accountRoutes, ...legendariesRoutes, ...healthRoutes]; a /ui route table appends here.
  • apps/web/src/features/account/routes.tsx:7 — route element shape { path, element: <…Page/> }; the *Page naming rule (spec 010 P1 #4) applies, so the gallery page is e.g. UiGalleryPage. "No nav item" = simply not adding a NavLink in App.tsx.

V6 — Do the Base UI wrappers pass the React Compiler at panicThreshold: 'all_errors'? (spec R15) ​

Question. R15 needs pnpm build to stay green — a compiler bail-out on Base UI usage would fail the build.

Verdict. Confirmed. A component rendering Base UI Field/Input/Dialog/Tooltip JSX compiled with no bail-out.

Evidence.

  • The compiler never touches Base UI's own code: @rolldown/plugin-babel's default exclude is /[\/\\]node_modules[\/\\]…/ (node_modules/@rolldown/plugin-babel/README.md:46-49), so only our src/ wrappers are compiled.
  • Spike (discarded per constitution): a throwaway src/_probe019.tsx rendering real Field/Input/ Dialog/Tooltip JSX, mounted via a temporary /_probe019 route, built clean — pnpm build (panda codegen && vite build): "443 modules transformed… built in 1.00s", no panic. The probe and the route edit were reverted; the working tree is clean.

F1 — Base UI is truly headless: nothing to import, but it injects functional inline <style>/<script> ​

No .css file exists in the package and package.json exports none — our Panda slot recipes are the only visual styling, and there is nothing to import. A few components inject functional inline <style>/<script> tags (not a theme sheet); CSPProvider exists to supply a nonce or disableStyleElements (csp-provider/CspProvider.d.ts).

Why it matters. Confirms the styling model for R3/R12 (Panda-only). If a strict CSP is added later, wrap the app root in CSPProvider — not needed now. Graduation candidate for design-system.md.

F2 — Base UI ships a Button, deliberately unused ​

Base UI has a button module, but the design uses a plain Panda Button (R5). The rationale generalises to a rule: a native <button> already has a button's full behaviour (focusable, Enter/Space activation, role, disabled), so wrapping Base UI adds a layer for no gain. Base UI's Button exists to make non-button elements behave like buttons — not our case.

Why it matters. Backs R5 and gives the DS a written rule: wrap Base UI per-component only where a native element falls short of the required behaviour. Graduation candidate for design-system.md.

F3 — Dialog construction details (modal) ​

With modal (default true), render a Dialog.Close inside Dialog.Popup for touch screen-reader dismissal, and use Dialog.Portal for the popup/backdrop. initialFocus/finalFocus on the popup tune focus placement (default: first tabbable). Why it matters. Plan-level detail for R7.

F4 — Tooltip prop locations ​

Delay props are on Tooltip.Trigger (delay default 600, closeDelay default 0), not Tooltip.Root (tooltip/trigger/TooltipTrigger.d.ts; Root destructure has none, tooltip/root/TooltipRoot.js:27-40). Tooltip.Provider is optional — it only enables shared delay-grouping across many tooltips (tooltip/provider/TooltipProvider.d.ts); a single tooltip works without one. Positioning (side, sideOffset, align) is on Tooltip.Positioner. Why it matters. Plan-level detail for R8, and confirms no app-root Provider is required.

Refuted claims ​

R8 (and P4 #2, SC2) — "the tooltip content is associated with the trigger for assistive tech" — REFUTED.

  • Believed: Base UI's Tooltip auto-wires the popup to the trigger (a role="tooltip" + aria-describedby relationship), so rendering the closed Tooltip would make the content available to screen readers.
  • True (V3 evidence): Base UI's Tooltip does no aria wiring. Per the pinned package's own docs, the content is visual-only and not announced; accessibility is the caller's job — the trigger must carry an aria-label that matches the content. For information that must be announced, the library directs you to Popover (with openOnHover), not Tooltip.
  • Change required in the spec (back to step 1 — human decision): R8, P4 #2, and SC2 must stop asserting auto-association. The accessible mechanism the closed Tooltip enforces instead is a required accessible name on the trigger (a label prop applied as aria-label, defaulted from content when it is a string), with the content documented as visual-only. An Out of scope line notes that essential, must-be-announced information belongs in a future Popover, not Tooltip. Applied — the human approved this fix (option 1, 2026-08-17); R8, P4 #2, SC2 and the P4 independent test are amended and the Popover Out of scope line is added.

Graduation ​

Findings to move to docs/architecture/ at step 6 (spec 019 already updates design-system.md/ react.md, so most land there as part of the work):

  • F2 — "wrap Base UI only where a native element falls short" → design-system.md (closed-component section).
  • The closed-Tooltip a11y contract (trigger carries an accessible name; content is visual-only) → design-system.md/react.md.
  • F1 — Base UI is headless, Panda is the only styling; CSPProvider is the escape hatch if a strict CSP is added later → design-system.md (styling seam).
  • F3/F4 — Dialog Dialog.Close-inside-popup and Tooltip prop locations are component-build notes; they live in the components' own code, not the architecture docs.