ADR-0013: Zero Component-Library UI¶
Status: Superseded by ADR-0035 for product surfaces Date: 2026-05-20
Supersession notice: This ADR's "no external component library" decision is superseded by ADR-0035 for all product surfaces (Dashboard, tables, modals, dropdowns, etc.). ADR-0035 adopts shadcn/ui + Radix primitives because the threshold conditions documented below (3+ of: modals, popovers, combobox, multi-select, ARIA tabs) have all been met. The zero-dependency principle remains valid for content rendering (markdown) and visualization (ReactFlow/dagre) — see ADR-0035 §"NOT adopted" for the preserved scope.
Context¶
Lion Studio's frontend uses Next.js 16 + React 19 + TypeScript + Tailwind CSS with no UI component library (no Radix, no shadcn, no Headless UI). All interactive components — Badge, StatusPill, Button, Toast, tabs, accordions, two-pane layouts, definition editors — are custom-built with Tailwind.
Note: the app does use visualization libraries (ReactFlow for PlayDag and WorkerCanvas, dagre for layout). "Zero component-library" means no general-purpose UI primitive library, not zero frontend dependencies.
This pattern emerged organically: the first components were simple enough that importing a library was unnecessary, and subsequent components followed the established style. The question arises with each new UI primitive: should we adopt a headless component library (Radix, Headless UI, Ark UI) or continue building custom?
The decision was explicitly tested during the ADR-0012 design review. The toast system (~100 LOC custom) was the first component where a library (react-hot-toast, sonner) would have been materially simpler. We chose custom.
Decision¶
Continue building custom components. No UI component library.
The threshold for reconsidering: when the app needs 3+ of the following simultaneously, adopting a headless primitive library becomes justified:
- Modal dialogs (not just toasts — actual blocking dialogs with focus trap)
- Popovers with positioning logic (dropdown menus, tooltips with arrows)
- Command palette / combobox with keyboard navigation and fuzzy search
- Multi-select / autocomplete inputs
- Accessible disclosure (tabs, accordions) with ARIA state management
Currently the app uses custom tabs (plugin detail) and collapsible accordions (play details, branch sections) that work without library-grade ARIA state management. These existing components do not count toward the threshold because they are simple show/hide toggles without focus-trap, arrow-key navigation, or roving tabindex requirements. The threshold counts complex primitives that genuinely need a headless library's accessibility infrastructure. The command palette (deferred to a later phase in ADR-0012) would be the trigger — when it ships, evaluate whether adopting Radix or similar is warranted.
What "custom" means in practice¶
- Layout primitives: flexbox/grid via Tailwind utility classes.
- Interactive components: React state + event handlers + Tailwind transitions.
- Theming: CSS custom properties in
globals.css, class-based dark/light toggle. - Accessibility: manual ARIA attributes where needed (not systematically audited).
- Animation: CSS transitions and Tailwind
animate-*utilities. No Framer Motion.
Approved exceptions — content-rendering primitives¶
Certain rendering tasks involve enough complexity that a hand-rolled implementation would duplicate significant library work without adding value. The threshold for approving a dependency as an exception to the zero-library rule is: the primitive simultaneously requires async parsing, a well-specified extension grammar, and tight React reconciliation integration — characteristics that individually justify custom code but together define a rendering pipeline.
react-markdown + remark-gfm are approved as the markdown rendering stack. Markdown rendering clears the exception threshold for the following reasons:
- GFM extension grammar — tables, task lists, autolinks, and strikethrough each have their own tokenizer rules. Implementing even a subset of GFM correctly is a non-trivial parser project.
- React reconciliation — naive innerHTML injection bypasses React's tree; a React-aware renderer is required for safe, diffable markdown output inside component trees.
- Async parsing pipeline — the remark/rehype AST pipeline enables safe HTML sanitization, lazy plugin loading, and future extension (e.g., syntax highlighting) without rewriting the renderer.
react-markdown + remark-gfm may be used wherever markdown rendering is genuinely needed: plan documents, agent/playbook descriptions, plugin manifests, show _show.md content, and session summaries. They are not a general escape hatch — UI layout, interactive components, and data display still follow the zero-library rule.
What we accept as trade-offs¶
- Accessibility coverage is best-effort, not systematic. This is acceptable for a power-user tool with one primary user. It would not be acceptable for a public-facing product.
- Each new primitive costs ~50-150 LOC of implementation. At the current pace (~1 new component per design phase), this is sustainable.
- Focus management, portal rendering, and scroll locking are done ad-hoc per component. If multiple components need these, a shared utility layer is preferable to a full library adoption.
Consequences¶
Positive
- Zero dependency means zero upgrade churn, zero breaking changes from upstream, zero style conflicts or specificity wars.
- Every component matches the design system exactly — no overriding library defaults or fighting opinionated styling.
- Bundle size stays minimal. No tree-shaking concerns.
- Full control over behavior: the toast system dismisses exactly when and how we want, the status pills use exactly the tokens we define.
Negative
- Accessibility is incomplete. A screen reader user would have a degraded experience on some interactive elements.
- Some patterns (focus trap, click-outside-to-close, scroll lock on overlay) get reimplemented per component instead of shared.
- New contributors must learn the custom component patterns rather than referring to a library's documentation.
Alternatives Considered¶
| Alternative | Why Rejected |
|---|---|
| Radix UI | Best headless option, but adds 10+ packages for primitives we don't need yet. Evaluate at command palette time. |
| shadcn/ui | Copy-paste model is appealing but brings Radix as a dependency and imposes its own abstraction layer on top of Tailwind. |
| Headless UI (Tailwind Labs) | Smaller surface area than Radix but less complete. Same "not needed yet" argument applies. |
| React Aria (Adobe) | Excellent accessibility but heavyweight and opinionated about state management. Overkill for this app. |