Headless UI — компоненты, которые предоставляют логику и доступность без какого-либо стиля. Вы получаете поведение (состояние, клавиатурная навигация, ARIA), а стиль — полностью ваш.
Зачем нужен Headless UI:
Популярные Headless библиотеки:
// Radix UI: Dropdown без стилей
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
function MyDropdown() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="my-trigger-styles">Опции ▾</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="my-menu-styles">
<DropdownMenu.Item className="my-item-styles" onSelect={() => edit()}>
Редактировать
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item className="my-danger-styles" onSelect={() => del()}>
Удалить
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}Compound Components — компоненты, которые работают вместе и неявно делят состояние через Context:
// Пример: Tab компонент в стиле Compound
// Контекст для передачи состояния между компонентами
const TabContext = createContext(null)
// Корневой компонент
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue)
return (
<TabContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabContext.Provider>
)
}
// Подкомпоненты через точечную нотацию
function TabList({ children }) {
return <div role="tablist">{children}</div>
}
function Tab({ value, children }) {
const { active, setActive } = useContext(TabContext)
return (
<button
role="tab"
aria-selected={active === value}
onClick={() => setActive(value)}
>
{children}
</button>
)
}
function TabPanel({ value, children }) {
const { active } = useContext(TabContext)
if (active !== value) return null
return <div role="tabpanel">{children}</div>
}
// Точечная нотация — удобный API
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
// Использование — декларативно и понятно!
function App() {
return (
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview">Обзор</Tabs.Tab>
<Tabs.Tab value="details">Детали</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">Обзорная информация...</Tabs.Panel>
<Tabs.Panel value="details">Детальная информация...</Tabs.Panel>
</Tabs>
)
}Compound Components строятся на Context для передачи состояния вниз:
// Accordion с полным контролем над разметкой
const AccordionContext = createContext(null)
function Accordion({ children, allowMultiple = false }) {
const [openItems, setOpenItems] = useState(new Set())
const toggle = (id) => {
setOpenItems(prev => {
const next = new Set(allowMultiple ? prev : [])
prev.has(id) ? next.delete(id) : next.add(id)
return next
})
}
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
)
}
function AccordionItem({ id, children }) {
const { openItems, toggle } = useContext(AccordionContext)
const isOpen = openItems.has(id)
return (
<div className="accordion-item">
<button
aria-expanded={isOpen}
aria-controls={`panel-${id}`}
onClick={() => toggle(id)}
>
{children[0]} {/* заголовок */}
</button>
<div
id={`panel-${id}`}
role="region"
hidden={!isOpen}
>
{children[1]} {/* содержимое */}
</div>
</div>
)
}Slots — способ передать именованный контент в разные места компонента:
// Компонент Card со слотами
function Card({ slots }) {
return (
<div className="card">
<div className="card__header">{slots.header}</div>
<div className="card__body">{slots.body}</div>
<div className="card__footer">{slots.footer}</div>
</div>
)
}
// Использование
<Card slots={{
header: <h2>Заголовок</h2>,
body: <p>Основной контент</p>,
footer: <button>Действие</button>,
}} />
// Или через children.filter() / React.Children.toArray
function Dialog({ children }) {
const title = React.Children.toArray(children).find(
c => c.type === Dialog.Title
)
const body = React.Children.toArray(children).find(
c => c.type === Dialog.Body
)
return (
<div role="dialog">
<header>{title}</header>
<main>{body}</main>
</div>
)
}// Uncontrolled: состояние внутри компонента
function UncontrolledInput() {
const [value, setValue] = useState('')
return <input value={value} onChange={e => setValue(e.target.value)} />
}
// Controlled: состояние снаружи, полный контроль
function ControlledInput({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
// Паттерн "управляемое с откатом" — поддерживает оба варианта
function SmartDropdown({ value: controlledValue, onChange, defaultValue }) {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(defaultValue)
const value = isControlled ? controlledValue : internalValue
const handleChange = (newValue) => {
if (!isControlled) setInternalValue(newValue)
onChange?.(newValue)
}
return <select value={value} onChange={e => handleChange(e.target.value)} />
}Headless Dropdown на ванильном JS: управление состоянием открытия/закрытия, выбор элементов, разделение триггера и содержимого
// Реализуем Headless Dropdown: только логика, без стилей.
// Паттерн Compound Components через возвращаемые части.
function createDropdown(items) {
let isOpen = false
let selectedItem = null
const listeners = { change: [], open: [], close: [] }
function emit(event, data) {
;(listeners[event] || []).forEach(fn => fn(data))
}
// Состояние
const state = {
getIsOpen: () => isOpen,
getSelected: () => selectedItem,
getItems: () => items,
}
// Trigger: кнопка-триггер (получает обработчики для прикрепления)
const trigger = {
// Атрибуты для кнопки (aria и т.д.)
getProps() {
return {
'aria-haspopup': 'listbox',
'aria-expanded': isOpen,
onClick: trigger.toggle,
}
},
toggle() {
isOpen ? trigger.close() : trigger.open()
},
open() {
isOpen = true
emit('open', null)
console.log('[Trigger] Открыт')
},
close() {
isOpen = false
emit('close', null)
console.log('[Trigger] Закрыт')
},
}
// Content: список элементов
const content = {
getProps() {
return {
role: 'listbox',
'aria-hidden': !isOpen,
}
},
getItemProps(item) {
return {
role: 'option',
'aria-selected': selectedItem === item,
onClick: () => content.selectItem(item),
}
},
selectItem(item) {
selectedItem = item
isOpen = false
emit('change', item)
emit('close', null)
console.log('[Content] Выбрано:', item.label)
},
}
// Поиск по клавиатуре
function handleKeyDown(key) {
if (!isOpen) {
if (key === 'Enter' || key === ' ') trigger.open()
return
}
if (key === 'Escape') { trigger.close(); return }
if (key === 'Enter' && selectedItem) { content.selectItem(selectedItem); return }
const currentIndex = items.indexOf(selectedItem)
if (key === 'ArrowDown') {
const next = items[Math.min(currentIndex + 1, items.length - 1)]
selectedItem = next
console.log('[Keyboard] Фокус на:', next?.label)
}
if (key === 'ArrowUp') {
const prev = items[Math.max(currentIndex - 1, 0)]
selectedItem = prev
console.log('[Keyboard] Фокус на:', prev?.label)
}
}
return {
state,
trigger,
content,
handleKeyDown,
on(event, fn) { listeners[event] = [...(listeners[event] || []), fn]; return this },
}
}
// --- Использование ---
const dropdown = createDropdown([
{ id: 1, label: 'React' },
{ id: 2, label: 'Vue' },
{ id: 3, label: 'Angular' },
{ id: 4, label: 'Svelte' },
])
dropdown
.on('change', item => console.log('onChange:', item.label))
.on('open', () => console.log('onOpen'))
.on('close', () => console.log('onClose'))
console.log('=== Открытие через триггер ===')
dropdown.trigger.toggle()
console.log('isOpen:', dropdown.state.getIsOpen()) // true
console.log('
=== Навигация клавиатурой ===')
dropdown.handleKeyDown('ArrowDown') // React -> Vue
dropdown.handleKeyDown('ArrowDown') // Vue -> Angular
dropdown.handleKeyDown('ArrowUp') // Angular -> Vue
console.log('
=== Выбор элемента ===')
const items = dropdown.state.getItems()
dropdown.content.selectItem(items[0])
console.log('Выбрано:', dropdown.state.getSelected()?.label) // React
console.log('isOpen после выбора:', dropdown.state.getIsOpen()) // false
console.log('
=== Props для рендеринга ===')
console.log('Trigger props:', dropdown.trigger.getProps())
dropdown.trigger.open()
console.log('Content props:', dropdown.content.getProps())
console.log('Item props:', dropdown.content.getItemProps(items[0]))Headless UI — компоненты, которые предоставляют логику и доступность без какого-либо стиля. Вы получаете поведение (состояние, клавиатурная навигация, ARIA), а стиль — полностью ваш.
Зачем нужен Headless UI:
Популярные Headless библиотеки:
// Radix UI: Dropdown без стилей
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
function MyDropdown() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button className="my-trigger-styles">Опции ▾</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="my-menu-styles">
<DropdownMenu.Item className="my-item-styles" onSelect={() => edit()}>
Редактировать
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item className="my-danger-styles" onSelect={() => del()}>
Удалить
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}Compound Components — компоненты, которые работают вместе и неявно делят состояние через Context:
// Пример: Tab компонент в стиле Compound
// Контекст для передачи состояния между компонентами
const TabContext = createContext(null)
// Корневой компонент
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue)
return (
<TabContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabContext.Provider>
)
}
// Подкомпоненты через точечную нотацию
function TabList({ children }) {
return <div role="tablist">{children}</div>
}
function Tab({ value, children }) {
const { active, setActive } = useContext(TabContext)
return (
<button
role="tab"
aria-selected={active === value}
onClick={() => setActive(value)}
>
{children}
</button>
)
}
function TabPanel({ value, children }) {
const { active } = useContext(TabContext)
if (active !== value) return null
return <div role="tabpanel">{children}</div>
}
// Точечная нотация — удобный API
Tabs.List = TabList
Tabs.Tab = Tab
Tabs.Panel = TabPanel
// Использование — декларативно и понятно!
function App() {
return (
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview">Обзор</Tabs.Tab>
<Tabs.Tab value="details">Детали</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview">Обзорная информация...</Tabs.Panel>
<Tabs.Panel value="details">Детальная информация...</Tabs.Panel>
</Tabs>
)
}Compound Components строятся на Context для передачи состояния вниз:
// Accordion с полным контролем над разметкой
const AccordionContext = createContext(null)
function Accordion({ children, allowMultiple = false }) {
const [openItems, setOpenItems] = useState(new Set())
const toggle = (id) => {
setOpenItems(prev => {
const next = new Set(allowMultiple ? prev : [])
prev.has(id) ? next.delete(id) : next.add(id)
return next
})
}
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
)
}
function AccordionItem({ id, children }) {
const { openItems, toggle } = useContext(AccordionContext)
const isOpen = openItems.has(id)
return (
<div className="accordion-item">
<button
aria-expanded={isOpen}
aria-controls={`panel-${id}`}
onClick={() => toggle(id)}
>
{children[0]} {/* заголовок */}
</button>
<div
id={`panel-${id}`}
role="region"
hidden={!isOpen}
>
{children[1]} {/* содержимое */}
</div>
</div>
)
}Slots — способ передать именованный контент в разные места компонента:
// Компонент Card со слотами
function Card({ slots }) {
return (
<div className="card">
<div className="card__header">{slots.header}</div>
<div className="card__body">{slots.body}</div>
<div className="card__footer">{slots.footer}</div>
</div>
)
}
// Использование
<Card slots={{
header: <h2>Заголовок</h2>,
body: <p>Основной контент</p>,
footer: <button>Действие</button>,
}} />
// Или через children.filter() / React.Children.toArray
function Dialog({ children }) {
const title = React.Children.toArray(children).find(
c => c.type === Dialog.Title
)
const body = React.Children.toArray(children).find(
c => c.type === Dialog.Body
)
return (
<div role="dialog">
<header>{title}</header>
<main>{body}</main>
</div>
)
}// Uncontrolled: состояние внутри компонента
function UncontrolledInput() {
const [value, setValue] = useState('')
return <input value={value} onChange={e => setValue(e.target.value)} />
}
// Controlled: состояние снаружи, полный контроль
function ControlledInput({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
// Паттерн "управляемое с откатом" — поддерживает оба варианта
function SmartDropdown({ value: controlledValue, onChange, defaultValue }) {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(defaultValue)
const value = isControlled ? controlledValue : internalValue
const handleChange = (newValue) => {
if (!isControlled) setInternalValue(newValue)
onChange?.(newValue)
}
return <select value={value} onChange={e => handleChange(e.target.value)} />
}Headless Dropdown на ванильном JS: управление состоянием открытия/закрытия, выбор элементов, разделение триггера и содержимого
// Реализуем Headless Dropdown: только логика, без стилей.
// Паттерн Compound Components через возвращаемые части.
function createDropdown(items) {
let isOpen = false
let selectedItem = null
const listeners = { change: [], open: [], close: [] }
function emit(event, data) {
;(listeners[event] || []).forEach(fn => fn(data))
}
// Состояние
const state = {
getIsOpen: () => isOpen,
getSelected: () => selectedItem,
getItems: () => items,
}
// Trigger: кнопка-триггер (получает обработчики для прикрепления)
const trigger = {
// Атрибуты для кнопки (aria и т.д.)
getProps() {
return {
'aria-haspopup': 'listbox',
'aria-expanded': isOpen,
onClick: trigger.toggle,
}
},
toggle() {
isOpen ? trigger.close() : trigger.open()
},
open() {
isOpen = true
emit('open', null)
console.log('[Trigger] Открыт')
},
close() {
isOpen = false
emit('close', null)
console.log('[Trigger] Закрыт')
},
}
// Content: список элементов
const content = {
getProps() {
return {
role: 'listbox',
'aria-hidden': !isOpen,
}
},
getItemProps(item) {
return {
role: 'option',
'aria-selected': selectedItem === item,
onClick: () => content.selectItem(item),
}
},
selectItem(item) {
selectedItem = item
isOpen = false
emit('change', item)
emit('close', null)
console.log('[Content] Выбрано:', item.label)
},
}
// Поиск по клавиатуре
function handleKeyDown(key) {
if (!isOpen) {
if (key === 'Enter' || key === ' ') trigger.open()
return
}
if (key === 'Escape') { trigger.close(); return }
if (key === 'Enter' && selectedItem) { content.selectItem(selectedItem); return }
const currentIndex = items.indexOf(selectedItem)
if (key === 'ArrowDown') {
const next = items[Math.min(currentIndex + 1, items.length - 1)]
selectedItem = next
console.log('[Keyboard] Фокус на:', next?.label)
}
if (key === 'ArrowUp') {
const prev = items[Math.max(currentIndex - 1, 0)]
selectedItem = prev
console.log('[Keyboard] Фокус на:', prev?.label)
}
}
return {
state,
trigger,
content,
handleKeyDown,
on(event, fn) { listeners[event] = [...(listeners[event] || []), fn]; return this },
}
}
// --- Использование ---
const dropdown = createDropdown([
{ id: 1, label: 'React' },
{ id: 2, label: 'Vue' },
{ id: 3, label: 'Angular' },
{ id: 4, label: 'Svelte' },
])
dropdown
.on('change', item => console.log('onChange:', item.label))
.on('open', () => console.log('onOpen'))
.on('close', () => console.log('onClose'))
console.log('=== Открытие через триггер ===')
dropdown.trigger.toggle()
console.log('isOpen:', dropdown.state.getIsOpen()) // true
console.log('
=== Навигация клавиатурой ===')
dropdown.handleKeyDown('ArrowDown') // React -> Vue
dropdown.handleKeyDown('ArrowDown') // Vue -> Angular
dropdown.handleKeyDown('ArrowUp') // Angular -> Vue
console.log('
=== Выбор элемента ===')
const items = dropdown.state.getItems()
dropdown.content.selectItem(items[0])
console.log('Выбрано:', dropdown.state.getSelected()?.label) // React
console.log('isOpen после выбора:', dropdown.state.getIsOpen()) // false
console.log('
=== Props для рендеринга ===')
console.log('Trigger props:', dropdown.trigger.getProps())
dropdown.trigger.open()
console.log('Content props:', dropdown.content.getProps())
console.log('Item props:', dropdown.content.getItemProps(items[0]))Создай React Compound Component для Accordion с использованием Context. Accordion должен включать: корневой компонент Accordion с провайдером контекста, компонент AccordionItem который читает контекст и показывает/скрывает содержимое. Используй паттерн composition с точечной нотацией (Accordion.Item).
В toggle: next.delete(id) и next.add(id). В isOpen: openItems.has(id). Provider value: { toggle, isOpen }. useContext(AccordionContext). isOpen(id) для проверки открытия. onClick={() => toggle(id). aria-expanded={open}. Показ содержимого: {open && ...}. Accordion.Item = AccordionItem.