- Replace .eslintrc.cjs with eslint.config.mjs (ESLint 9 flat config)
using direct eslint-plugin-solid + @typescript-eslint/parser approach
- Add @typescript-eslint/parser to root devDependencies
- Add main/module/types top-level fields to packages/core/package.json
- Add resolve.conditions to packages/core/vite.config.ts
- Create packages/core/tsconfig.test.json for test type-checking
- Remove empty paths:{} from packages/core/tsconfig.json
224 lines
4.7 KiB
Markdown
224 lines
4.7 KiB
Markdown
[Back to Blog](https://mohameddewidar.com/blog)
|
|
|
|
# Headless UI Patterns in React
|
|
|
|
Headless UI is a pattern that separates **logic** from **presentation**. The component controls state and behavior while leaving all markup and styling to the consumer. This pattern is used in modern design systems and UI libraries because it gives developers full visual freedom without sacrificing predictable logic.
|
|
|
|
This article explains the pattern clearly, shows how to implement it in React, and provides real world examples that scale in production systems.
|
|
|
|
## 1\. What Headless UI Means
|
|
|
|
A headless component does not render its own layout. It exposes:
|
|
|
|
- state
|
|
- actions
|
|
- event handlers
|
|
- accessibility helpers
|
|
|
|
The consumer decides how the UI looks.
|
|
|
|
Example of a headless toggle hook:
|
|
|
|
```
|
|
function useToggle(initial = false) {
|
|
const [on, setOn] = useState(initial);
|
|
return {
|
|
on,
|
|
toggle: () => setOn((v) => !v),
|
|
setOn
|
|
};
|
|
}
|
|
```
|
|
|
|
Usage with custom UI:
|
|
|
|
```
|
|
const toggle = useToggle();
|
|
|
|
<button onClick={toggle.toggle}>
|
|
{toggle.on ? "On" : "Off"}
|
|
</button>
|
|
```
|
|
|
|
The hook manages behavior. The UI is entirely customizable.
|
|
|
|
## 2\. Why Headless UI Matters
|
|
|
|
Headless components solve real problems:
|
|
|
|
### Problem 1. Components that restrict styling
|
|
|
|
Teams often fight against rigid components that force markup or CSS structures.
|
|
|
|
### Problem 2. Hard to integrate with custom design systems
|
|
|
|
Headless logic works with any HTML structure and any design system.
|
|
|
|
### Problem 3. Difficult to reuse logic without duplicating UI
|
|
|
|
The logic lives in one place. The UI can vary by use case.
|
|
|
|
### Problem 4. Mixed concerns
|
|
|
|
UI, behavior, and state are combined in one file. Headless UI splits these into clear boundaries.
|
|
|
|
## 3\. Headless UI With Render Props
|
|
|
|
Render props expose behavior through a function.
|
|
|
|
```
|
|
function Dropdown({ children }) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return children({
|
|
open,
|
|
toggle: () => setOpen((v) => !v)
|
|
});
|
|
}
|
|
```
|
|
|
|
Usage:
|
|
|
|
```
|
|
<Dropdown>
|
|
{({ open, toggle }) => (
|
|
<>
|
|
<button onClick={toggle}>Menu</button>
|
|
{open && <div className="menu">...</div>}
|
|
</>
|
|
)}
|
|
</Dropdown>
|
|
```
|
|
|
|
This gives complete visual control.
|
|
|
|
## 4\. Headless UI With Context and Compound Components
|
|
|
|
This pattern is cleaner for multi part components like modals, dropdowns, and accordions.
|
|
|
|
### Step 1. Create context driven logic
|
|
|
|
```
|
|
const AccordionContext = createContext(null);
|
|
|
|
function Accordion({ children }) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
const value = {
|
|
open,
|
|
toggle: () => setOpen((v) => !v)
|
|
};
|
|
|
|
return (
|
|
<AccordionContext.Provider value={value}>
|
|
{children}
|
|
</AccordionContext.Provider>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Step 2. Separate UI components
|
|
|
|
```
|
|
function AccordionTrigger({ children }) {
|
|
const { toggle } = useContext(AccordionContext);
|
|
return <button onClick={toggle}>{children}</button>;
|
|
}
|
|
|
|
function AccordionContent({ children }) {
|
|
const { open } = useContext(AccordionContext);
|
|
return open ? <div>{children}</div> : null;
|
|
}
|
|
```
|
|
|
|
Usage:
|
|
|
|
```
|
|
<Accordion>
|
|
<AccordionTrigger>Show Details</AccordionTrigger>
|
|
<AccordionContent>Here are the details...</AccordionContent>
|
|
</Accordion>
|
|
```
|
|
|
|
The logic is shared. The UI remains flexible.
|
|
|
|
## 5\. Building a Headless Select Component
|
|
|
|
Logic:
|
|
|
|
```
|
|
function useSelect(items) {
|
|
const [selected, setSelected] = useState(null);
|
|
|
|
return {
|
|
items,
|
|
selected,
|
|
select: (item) => setSelected(item)
|
|
};
|
|
}
|
|
```
|
|
|
|
Usage:
|
|
|
|
```
|
|
const select = useSelect(["A", "B", "C"]);
|
|
|
|
<ul>
|
|
{select.items.map((item) => (
|
|
<li key={item} onClick={() => select.select(item)}>
|
|
{item} {select.selected === item ? "(selected)" : ""}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
```
|
|
|
|
## 6\. Accessibility in Headless UI
|
|
|
|
A headless design system should expose accessibility helpers.
|
|
|
|
```
|
|
function useDialog() {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
return {
|
|
open,
|
|
triggerProps: {
|
|
"aria-haspopup": "dialog",
|
|
onClick: () => setOpen(true)
|
|
},
|
|
dialogProps: {
|
|
role: "dialog",
|
|
"aria-modal": true
|
|
},
|
|
close: () => setOpen(false)
|
|
};
|
|
}
|
|
```
|
|
|
|
Usage:
|
|
|
|
```
|
|
const dialog = useDialog();
|
|
|
|
<button {...dialog.triggerProps}>Open</button>
|
|
|
|
{dialog.open && (
|
|
<div {...dialog.dialogProps}>
|
|
Modal content
|
|
<button onClick={dialog.close}>Close</button>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
## 7\. Anti Patterns to Avoid
|
|
|
|
- Forcing DOM structure
|
|
- Mixing styling inside the logic
|
|
- Bloated configuration props
|
|
- Hooks that try to manage both logic and rendering
|
|
|
|
## Final Thoughts
|
|
|
|
Headless UI patterns help teams build flexible and reusable components that scale with evolving design requirements. By separating logic from presentation, React applications become easier to maintain, more customizable, and more predictable.
|
|
|
|
[Back to Blog](https://mohameddewidar.com/blog) |