Headless UI provides unstyled React interaction components for dialogs, listboxes and switches. This guide focuses on composing their native focus, selection and popup behavior with ordinary CSS, using a document-sharing flow as the working example.
Native @headlessui/react 2.2.10 with React 19. All colors, type, spacing, and layout are original example CSS, not official Headless UI tokens. · Implementation reference
02
Headless UI Design System Design Reference
Headless UI has no official visual token scale. Every numeric or color value below belongs to this OpenDesign example; the linked documentation supplies behavior and state APIs. Package tested: @headlessui/react 2.2.10.
Headless UI Design System Compose the Behavior Before Styling the Screen
Compose the Behavior Before Styling the Screen
Dialog, Listbox and Switch each model a different interaction. Dialog controls a bounded task and its focus context; Listbox represents one committed choice; Switch represents an independent Boolean setting. Combining them does not require combining their state: the invitation draft, selected permission and public-link setting remain separate.
The example uses the React component API with direct imports such as DialogPanel and ListboxButton. Its dialog contains a listbox, so popup positioning and dismissal must work inside an existing overlay. Start with that composition before adding the document panel and access summary around it. The supplied CSS is one complete appearance for these native parts.
Headless UI Design System Dialog, Listbox, and Switch Anatomy
Keep the overlay boundaries intentional
DialogBackdrop is a sibling of the fixed positioning container. DialogPanel is the bounded white panel inside that container; it does not fill the viewport. DialogTitle names the task and Description explains what closing does. The native input has data-autofocus, and Headless UI manages the surrounding dialog focus behavior.
A listbox selects a value; a switch changes a boolean
ListboxButton shows the selected role. ListboxOptions uses anchor="bottom" and a width based on --button-width, so the popup aligns with its trigger. Each ListboxOption has a stable string value. Native data-focus and data-selected attributes drive two distinct CSS treatments.
The public-link Switch sits in Field with Label, rather than an unlabeled painted pill. Its checked state changes the accessible control and the visible explanatory sentence. The local invitation is independent of the public-link setting.
Headless UI Design System Separate Selection, Dismissal, and Local Confirmation
The state model used here
Opening initializes a fresh recipient draft and the Can view role. Cancel, Escape, and an outside click close the dialog; none adds a recipient. Submitting validates the email, rejects an address already present, then appends an invitation to memory and closes. The confirmation explicitly says no email was sent.
The permission listbox changes the draft role, not the existing recipient’s access. Public-link state changes a separate boolean. This avoids the common mistake of treating every close event as permission persistence.
Invalid input: keep the dialog open and associate the error with the email.
Duplicate recipient: leave the existing access list unchanged.
Accepted local invitation: update the list and announce the preview-only result.
Headless UI Design System Style Selection and Keyboard Position Independently
Style Selection and Keyboard Position Independently
ListboxOption exposes data-focus and data-selected for different conditions. The focused option receives the pale accent treatment, while the committed permission receives a check mark. A keyboard user can therefore move through alternatives without losing track of the existing choice.
The same distinction applies outside the popup: the error role belongs to the invalid recipient field, while the Switch’s checked appearance belongs to public-link access. Scope CSS to the component state it describes. A broad selector that colors every focused or checked element identically would obscure these different meanings.
Headless UI Design System Make Document Identity and Permissions Readable
Type settings that can be reproduced
The document title is 28px with a 1.25 line height, while the dialog title is 20px with a 1.3 line height. Body text is 16px/1.5 in the platform system font. Field labels and helper copy are 14px. These exact values are in style.css; readers do not need to infer them from a screenshot.
The email in the access list uses overflow-wrap:anywhere so an address cannot force its column wider. The permission remains on a separate line. Inside the dialog, the complete document title and selected permission are visible at the moment the invitation is added.
Headless UI Design System Complete Overlay Positioning and Responsive CSS
The layout is not hidden in a prerequisite
The supplied stylesheet includes the entire overlay: fixed inset positioning, backdrop opacity, z-index, maximum width, vertical scrolling, and padding. Below 700px the document panels stack, and the dialog reduces its padding. No Tailwind build is assumed.
Keep the positioning container distinct from DialogPanel. The container covers the available viewport and provides edge padding; the panel provides the bounded content surface. Give longer dialog content a scrolling region so the recipient field, permission choice and dismissal actions remain reachable at narrow widths.
Headless UI Design System Use Native State and Focus Behavior
Keyboard verification
Open the dialog and verify initial focus on the recipient. Reach the permission trigger with Tab; use arrow keys and Enter to choose a role. Escape dismisses the active popup or dialog according to the native component’s current focus context. After closing the dialog, focus should return to the sharing trigger.
The email uses aria-invalid and aria-describedby for its custom error. The check mark in an option is decorative because Listbox supplies selection semantics. The public-link control has a visible Label and the explanatory sentence remains after toggling. Reduced-motion CSS removes the decorative switch-thumb transition.
Headless UI Design System Use One React API Generation Consistently
Use One React API Generation Consistently
The complete React entry mounts App, whose stylesheet supplies the overlay geometry and state selectors. DialogPanel, DialogBackdrop, ListboxButton and ListboxOptions are direct imports in the verified 2.2.10 example. Older compound-component spellings should be migrated deliberately instead of mixed into this composition.
ListboxOptions uses anchor="bottom" and the native --button-width variable to align the options to the trigger. Those details are part of the integration, not optional decoration: removing the positioning container or replacing the width with an unrelated fixed value can break a nested popup even when the closed field still looks correct.
Install @headlessui/react in the React project.
Save App.jsx and style.css from the examples, then replace main.jsx as shown.
Run the development server and test dialog dismissal, permission selection, and duplicate-email recovery.
Headless UI Design System Inspect a Real Permission Interaction
Acceptance conditions
A useful review begins with a document and an existing recipient, not an empty component gallery. Add a valid invitation as Can comment, then reopen and try the same email. Confirm that the original row is retained and the duplicate produces a specific error. Turn on the public link and inspect its independent explanation.
The example demonstrates local UI state only. It provides neither recipient search nor a sharing backend. Connecting a real service requires a pending/error response model and an explicit policy for concurrent permission changes; the native primitives do not decide authorization rules.
Headless UI Design System Component Examples
App.jsx — native sharing composition
Complete controlled Dialog, permission Listbox, labeled Switch, and local validation.
jsx
import React, { useState } from 'react';
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle, Description, Listbox, ListboxButton, ListboxOptions, ListboxOption, Switch, Field, Label } from '@headlessui/react';
import './style.css';
const roles = ['Can view', 'Can comment', 'Can edit'];
export default function App() {
const [open,setOpen]=useState(false),[role,setRole]=useState(roles[0]),[publicLink,setPublicLink]=useState(false),[email,setEmail]=useState(''),[error,setError]=useState(''),[notice,setNotice]=useState('');
const [people,setPeople]=useState([{email:'[email protected]',role:'Can edit'}]);
const start=()=>{setEmail('');setError('');setRole(roles[0]);setOpen(true)};
const submit=e=>{e.preventDefault();if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)){setError('Enter a complete email address.');return;}if(people.some(p=>p.email===email)){setError('This person already appears in the access list.');return;}setPeople([...people,{email,role}]);setOpen(false);setNotice('Invitation added to this local example only. No email was sent.');};
return <main className='sharing-shell'>
<header><div><span className='eyebrow'>HEADLESS UI / CUSTOM SURFACE</span><h1>Quarterly brief</h1><p className='muted'>Research · Updated today</p></div><button className='primary' onClick={start}>Share document</button></header>
<div className='document-grid'><article className='paper'><span className='tag'>WORKING DOCUMENT</span><h2>Make the next decision clearer.</h2><p>Three research priorities for the coming quarter, shared with the people who need to review them.</p><div className='rule'/><h3>01 / Understand the handoff</h3><p>Compare where context is lost between research notes and the next product decision.</p><h3>02 / Review access deliberately</h3><p>Keep editing rights separate from the ability to read a document.</p></article>
<aside className='access'><h2>People with access</h2><p className='muted'>The list below is local demonstration state.</p>{people.map((p,i)=><div className='person' key={p.email}><span className='avatar'>{i===0?'AK':'+'}</span><div><strong>{p.email}</strong><small>{p.role}</small></div></div>)}<Field className='link-setting'><Label>Public link</Label><Switch checked={publicLink} onChange={setPublicLink} className='switch'><span /></Switch></Field><p className='link-detail'>{publicLink?'Anyone with the example link could view this document. This changes only the local preview.':'Restricted to the named people in this example.'}</p><p role='status' className='notice'>{notice}</p></aside></div>
<Dialog open={open} onClose={()=>setOpen(false)} className='dialog-root'><DialogBackdrop className='backdrop'/><div className='dialog-position'><DialogPanel className='dialog-panel'><DialogTitle as='h2'>Share quarterly brief</DialogTitle><Description className='muted'>Choose one recipient and a permission. Closing discards this invitation draft.</Description><form noValidate onSubmit={submit}><label className='field-label' htmlFor='recipient'>Recipient email</label><input id='recipient' data-autofocus type='email' value={email} onChange={e=>{setEmail(e.target.value);setError('')}} aria-invalid={!!error} aria-describedby={error?'email-error':undefined} placeholder='[email protected]'/>{error&&<p id='email-error' role='alert' className='error'>{error}</p>}<Listbox value={role} onChange={setRole}><Label className='field-label'>Permission</Label><ListboxButton className='role-button'>{role}<span aria-hidden='true'>⌄</span></ListboxButton><ListboxOptions anchor='bottom' className='role-options'>{roles.map(value=><ListboxOption key={value} value={value} className='role-option'><span>{value}</span><span className='selected' aria-hidden='true'>✓</span></ListboxOption>)}</ListboxOptions></Listbox><p className='muted permission-help'>{role==='Can edit'?'May change document content in this example.':role==='Can comment'?'May add comments without editing content.':'May read the document without changing it.'}</p><div className='dialog-actions'><button type='button' onClick={()=>setOpen(false)}>Cancel</button><button className='primary' type='submit'>Add invitation</button></div></form></DialogPanel></div></Dialog>
<footer>Native Dialog, Listbox, and Switch behavior. Original CSS below supplies all visual values; Headless UI does not ship this palette.</footer>
</main>;
}
All visual tokens, state selectors, overlay placement, and responsive rules required by App.jsx. These are example choices, not official Headless UI defaults.
The full styling and component files are supplied above. Requires the Node version supported by the selected Vite release. The demo bundles the same native component package locally.
text
# Terminal
npm create vite@latest sharing-demo -- --template react
cd sharing-demo
npm install
npm install @headlessui/[email protected]
# Save the complete examples as src/App.jsx and src/style.css.
# Replace src/main.jsx with:
import React from 'react';
import {createRoot} from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App/>);
# Terminal
npm run dev
A Prompt for Your Project
Agent Prompt
Use the supplied Headless UI App.jsx and style.css. Preserve the native Dialog, Listbox, and Switch composition, separate draft permissions from confirmed access, and keep all focus and selected-state selectors. Replace the local invitation append with our authorized API, including pending and failed states; never save on onClose. Do not describe the custom CSS palette as an official Headless UI theme.
05
Headless UI Design System Sources and Scope
Original OpenDesign visual configuration and React example using native Headless UI 2.2.10 behavior. CSS values are explicitly application choices; Headless UI does not provide this theme. Invitations and permission changes exist only in local preview memory.