Nychthemeron/docs/superpowers/specs/2026-07-13-remove-reka-ui-design.md
Matthew L McPeak ee9fcdf4dd docs: add design spec for removing reka-ui dependency
Plans a hand-rolled replacement for reka-ui across Button, Checkbox,
Switch, RadioGroup, Dialog, and Select to drop the dependency (and its
peerDependency burden on consumers) while preserving the current public
API and visual design exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 17:07:11 -04:00

8.4 KiB

Remove reka-ui dependency

Context

The library recently finished migrating off PrimeVue onto a shadcn-vue-style component set (ff02f7e chore: remove PrimeVue and the ThemeEngine, migration complete). That migration adopted reka-ui (the Vue port of Radix primitives) as the headless behavior layer under Button, Checkbox, Switch, RadioGroup, Dialog, and Select. reka-ui is currently a peerDependency of @nychthemeron/library, meaning every consumer of the library has to install it too.

Goal: drop reka-ui entirely — fewer dependencies for the library and its consumers, no external headless-UI surface to track for breaking changes — by hand-rolling the behavior each component needs directly in this repo.

This is an internal implementation swap. No component's public props, emits, slots, or CSS classes change; Storybook stories in packages/playground should work unmodified against the new implementations.

Scope

Files currently importing from reka-ui (24 total, packages/library/src/components/ui/):

  • button/Button.vue
  • checkbox/Checkbox.vue
  • switch/Switch.vue
  • radio-group/RadioGroup.vue, radio-group/RadioGroupItem.vue
  • dialog/*.vue (10 files: Dialog, DialogTrigger, DialogContent, DialogOverlay, DialogClose, DialogTitle, DialogDescription, DialogFooter, DialogScrollContent, plus the already-presentational DialogHeader which doesn't import reka-ui but lives in the same family)
  • select/*.vue (11 files: Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectItemText, SelectLabel, SelectGroup, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton)

Key decisions

Dialog is built as a fully custom div + Teleport, not on the native <dialog> element. Native <dialog> would give focus-trap/ESC/top-layer behavior for free, but its close() removes the element from the render tree immediately, which fights the fade/zoom exit animation the current design already has (data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95). A <Transition>-driven custom implementation lets the leave animation play out naturally before unmount.

Select is a fully custom listbox, not a native <select>. The current design has a styled popover (rounded corners, shadow, slide/fade animation, scroll-up/down buttons) that native <select> cannot reproduce — its dropdown is unstyleable OS chrome in every browser. Given the existing Storybook stories only exercise flat item lists (no search/multi-select), a hand-rolled listbox with manual keyboard nav and simple fixed-position placement is sufficient and preserves the current visual design exactly.

No positioning library. Select's popover placement is computed manually via getBoundingClientRect() (flip above the trigger when there's insufficient room below, recalculate on scroll/resize while open) rather than pulling in @floating-ui/dom or using CSS anchor positioning (the latter is Chrome/Edge-only today, not viable for full browser support).

Shared internal primitives

New composables/utilities under packages/library/src/lib/ (or similar), used across the affected components instead of one large headless library:

  • Slot (asChild helper) — clones the single child VNode and merges the parent's props/listeners onto it. Needed for Button's asChild, DialogTrigger as-child, DialogClose as-child, SelectIcon as-child. This is the one piece of VNode-cloning plumbing kept in the codebase.
  • Portal — Vue's built-in <Teleport to="body"> directly; no wrapper component needed.
  • useFocusTrap — on activation, moves focus into a container, cycles Tab/Shift+Tab within its focusable descendants, restores focus to the previously-focused element on deactivation. Used by Dialog and Select.
  • useDismissableLayer — closes on Escape keydown and on click/pointerdown outside a given container. Used by Dialog (overlay click + Escape) and Select (outside click + Escape).
  • useRovingFocus — arrow-key navigation across a set of items with a single tab stop (only the active item is in the tab order). Used by RadioGroup.
  • usePopoverPosition — computes fixed top/left for a floating element relative to a trigger's getBoundingClientRect(), flips above/below based on available viewport space, recalculates on scroll/resize while open. Used by Select's listbox.
  • Scroll lock — reuse @vueuse/core's useScrollLock (already a dependency) rather than writing a new composable. Applied to <body> while Dialog or Select is open.

Per-component plan

  • Button — replace Primitive with <component :is="as"> (default 'button'), using the new Slot helper when asChild is true.

  • Checkbox / Switch — reka's CheckboxRoot/SwitchRoot currently render as a <button role="checkbox|switch"> with aria-checked and a data-checked/data-unchecked attribute that the existing Tailwind classes key off of (e.g. data-checked:bg-primary). Reimplement that directly: a <button type="button"> managing v-model, setting aria-checked, data-checked/data-unchecked, forwarding disabled/aria-invalid, toggling on click (Space/Enter activation is native <button> behavior, no extra handling needed). Markup and classes stay identical — no visual change.

  • RadioGroup / RadioGroupItemrole="radiogroup" wrapper + role="radio" buttons using useRovingFocus for arrow-key movement and single-tab-stop behavior; v-model on the group for the selected value.

  • Dialog familyDialog (root) holds open state via provide/inject, matching the current v-model:open API. DialogTrigger toggles it (asChild supported via Slot). DialogContent teleports to body, renders overlay + panel, applies useFocusTrap + useDismissableLayer + scroll lock while open, and drives the fade/zoom animation with <Transition>. DialogTitle / DialogDescription generate and wire aria-labelledby / aria-describedby back onto the content. DialogOverlay, DialogHeader, DialogFooter, DialogClose, DialogScrollContent are thin/presentational already or need only a trivial context hookup.

  • Select familySelect (root) holds open/modelValue via provide/inject. SelectTrigger is a role="combobox" aria-expanded button. SelectContent teleports, positions itself with usePopoverPosition, traps focus, and owns keyboard handling: Up/Down move a highlighted item, Enter/Space selects, Escape/outside-click closes, typeahead jumps to the first item matching typed characters. SelectItem / SelectItemText / SelectLabel / SelectGroup / SelectSeparator read highlighted/selected state from the injected context. SelectScrollUpButton / SelectScrollDownButton become simple "is the viewport scrolled away from the top/bottom" indicators driven by a scroll listener, rather than reka's internal viewport machinery.

This family carries the most real accessibility surface (the listbox keyboard pattern), so beyond unit tests it needs a manual pass in Storybook against the existing stories: keyboard nav, focus return, and Escape/outside-click behavior.

Package/build changes

  • packages/library/package.json: remove reka-ui from peerDependencies.
  • packages/library/vite.config.ts: remove reka-ui from the Rollup external/output.globals config.
  • No new dependencies added.

Testing

  • Vitest unit tests (@vue/test-utils is an existing but currently-unused devDependency) for the composables with real logic and failure modes: useFocusTrap, useRovingFocus, useDismissableLayer, and Select's keyboard/typeahead handling.
  • Presentational components remain covered by the existing Storybook stories rather than new component-level tests.
  • Manual verification: exercise every existing Storybook story for Button/Checkbox/Switch/RadioGroup/Dialog/Select in a browser (headless Chromium per the existing Storybook screenshot setup), checking keyboard navigation, focus return, and Escape/outside-click behavior by hand, not just visual diffing.

Non-goals

  • No visual/CSS changes to any component.
  • No new features (multi-select, search/combobox, non-modal dialogs, etc.) beyond what the current reka-ui-backed components already support and the existing stories exercise.
  • No changes to consumers outside this repo's packages/playground; the public component API is preserved exactly.