When flintwork had 3 components (Button, Dialog, Tabs), every component was its own island. Each one had its own hooks, its own keyboard handling, its own focus management. The code worked but nothing talked to anything else.
When it grew to 8 (adding Menu, Select, Popover, Accordion, Tooltip), the codebase went through a structural shift I didn't plan for. Patterns emerged that only become visible when you have enough components to compare.
This is about what those patterns are and why they matter more than any individual component.
Four components that are secretly the same thing
Dialog, Popover, Menu, and Select look completely different to a user. Different triggers, different content, different interaction models. But internally they all do the same three things when they open:
- Trap focus inside the content
- Close when you click outside
- Close when you press Escape
The code is identical:
// All four components do exactly this in their Content component:
useFocusTrap(contentRef, { enabled: isOpen });
useClickOutside(contentRef, () => onOpenChange(false));
useEffect(() => {
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation();
onOpenChange(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onOpenChange]);
The behavioral difference between these four components lives entirely in their ARIA layer:
| Component | role | aria-modal | aria-haspopup | Trigger |
|---|---|---|---|---|
| Dialog | dialog |
true |
dialog |
Opens only |
| Popover | none | none | dialog |
Toggles |
| Menu | menu |
none | menu |
Toggles |
| Select | listbox |
none | listbox |
Toggles |
A screen reader user experiences Dialog and Popover as completely different things. But the code that powers them is the same three hooks composed the same way. The ARIA attributes are the differentiator, not the behavior.
This is something you can't see with 3 components. You need at least 4 overlapping patterns before the shared shape becomes obvious.
The decision I'm most glad I made early
Every hook is internal. Consumers never call useFocusTrap directly. They use <Dialog.Content> which calls it internally.
This seemed like a small decision at the time. With 8 components, it turned out to be the most important architectural choice in the system. Here's why:
When I needed to change how useFocusTrap handles initial focus placement, I changed one file. If consumers were calling useFocusTrap directly, that's a breaking change to a public API. With internal hooks, it's an implementation detail that no consumer code depends on.
The compound component API is the public contract. The hooks are the private implementation. This boundary is what lets the system evolve without breaking consumers.
When not to share: typeahead
Menu and Select both support typeahead search. Type a character and focus jumps to the first item that starts with that character. Same 500ms buffer timeout, same textContent matching logic.
The obvious move is to add typeahead to useRovingTabIndex since both Menu and Select already use it. I didn't.
Typeahead is specific to menu and listbox patterns. Tabs use roving tabindex but don't have typeahead. A toolbar would use roving tabindex but wouldn't have typeahead either. Adding it to the generic hook would pollute a behavior primitive with pattern-specific logic.
Instead, typeahead is a keydown handler directly in MenuContent and SelectContent. Both implementations are structurally identical. If a third component needs typeahead, that's when it gets extracted into useTypeahead.
Two instances is coincidence. Three is a pattern. Don't extract at two.
The Portal positioning tradeoff
Every component that renders floating content (Popover, Menu, Select, Tooltip) needs positioning. The obvious choice is to bundle @floating-ui/react and handle it internally.
I deliberately didn't. flintwork provides Portal for stacking context escape but leaves positioning to the consumer. The consumer can use CSS, @floating-ui/react, or whatever they want.
This was the hardest scope decision because it increases the barrier to entry. A consumer has to bring their own positioning. But the alternative is coupling the entire library to a specific positioning dependency that may not match the consumer's existing setup.
The compromise: the docs site shows a recommended pattern using CSS position: fixed with container refs. It works for the common case. If someone needs viewport-aware collision detection, they add @floating-ui/react themselves.
Not every library needs to solve every problem. Knowing where to draw the scope boundary is as important as what you include.
Accordion broke the pattern
Every component up to Accordion shared a simple context model: Root provides state, children consume it. One level of context.
Accordion needs two levels. The root tracks which panels are open (an array of values). Each item tracks whether that specific item is open. An AccordionItem needs to know its own open state AND whether it should be open based on the root's selection model (single vs multiple).
// Root context: which panels are open
const AccordionContext = createContext<{
value: string[];
onToggle: (itemValue: string) => void;
type: 'single' | 'multiple';
}>();
// Item context: is THIS panel open
const AccordionItemContext = createContext<{
isOpen: boolean;
value: string;
}>();
AccordionItem reads from root context to determine its state and provides item context to its children (Trigger and Content). The Trigger doesn't need to know about the root's selection model. It only needs isOpen and toggle from its item context.
This two-level context pattern didn't exist in the system until Accordion forced it. It's the kind of structural change that only surfaces when you're actually building components, not when you're designing the architecture upfront.
What I'd tell someone starting a design system
Start with 3 components. Ship them. Use them. Then build 5 more.
The architecture you design upfront for 3 components will be wrong for 8. That's fine. The hooks you write for Dialog will turn out to be the same hooks you need for Popover. But you won't see that until Popover exists. The context model that works for Tabs will need a second level for Accordion. But you won't know that until Accordion forces the change.
The goal of v1 isn't to design the perfect system. It's to build enough components that the shared patterns become visible. Then you refactor toward those patterns. That's how the architecture emerges rather than being imposed.
flintwork's source is on GitHub and the docs are live at flintwork.vercel.app if you want to see how this looks in practice.
Top comments (0)