When Your Settings Modal Looks Perfect But Traps Focus Behind Itself
You ship a settings panel. Design sign-off was clean. Two weeks later, a customer using VoiceOver reports they cannot rename their workspace because focus jumps to the page footer mid-dialog. You add tabindex="-1" to the overlay. Another customer on Windows Narrator complains the Escape key closes the browser tab instead of the modal. You patch keyboard listeners. Six months in, you maintain 140 lines of focus management that breaks whenever someone nests a dropdown inside the dialog.
The expensive defect was not the border radius. It was treating modal semantics as a styling problem.
I evaluated Nutlope/hallmark as design guidance, not as the owner of interaction contracts. Its anti-slop focus addresses visual sameness in AI-generated interfaces. The integration boundary matters: let design direction shape hierarchy and composition; application code must enforce semantics, state transitions, and motion constraints.
This is an architectural adoption pattern based on that stated scope, not a claim that I audited the repository's implementation.
The boundary: design intent outside, interaction guarantees inside
For a reusable rename dialog, I separate three responsibilities.
The application owns the saved name and persistence. The component owns the temporary draft and submission boundary. Radix Dialog owns modal behavior: focus containment, Escape dismissal, accessible labeling, and focus restoration under its normal lifecycle.
Tailwind handles visible states. A small CSS layer handles responsive action layout and entrance motion.
That separation prevents a common production failure: a visually customized modal gradually replacing accessibility behavior with document listeners and hand-maintained lists of focusable elements. Those lists break when validation inserts another control, a button becomes disabled, or nested content changes.
The implementation below assumes a working React/TypeScript application with Tailwind CSS v4. Install @radix-ui/react-dialog; for Tailwind's Vite integration, install tailwindcss and @tailwindcss/vite, then register tailwindcss() alongside the React plugin in the existing Vite configuration.
A dialog with a narrow contract
This component deliberately accepts a synchronous onSave. It commits a valid draft and closes. Network persistence needs a separate pending-and-error contract; pretending every save completes instantly is another tutorial shortcut that leaks into production.
import { useId, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog";
type RenameDialogProps = {
name: string;
onSave: (name: string) => void;
};
const button =
"rounded-md px-4 py-2 text-sm font-medium " +
"focus-visible:outline-2 focus-visible:outline-offset-2 " +
"focus-visible:outline-indigo-600";
export function RenameDialog({ name, onSave }: RenameDialogProps) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(name);
const fieldId = useId();
return (
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (next) setDraft(name);
setOpen(next);
}}
>
<Dialog.Trigger asChild>
<button className={`${button} border border-slate-300`}>
Rename workspace
</button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-40 bg-slate-950/50" />
<Dialog.Content
className="rename-dialog fixed top-1/2 left-1/2 z-50
max-h-[calc(100dvh_-_2rem)] w-[calc(100%_-_2rem)]
max-w-lg -translate-x-1/2 -translate-y-1/2
overflow-y-auto rounded-xl border border-slate-200
bg-white p-6 text-slate-950 shadow-xl"
>
<Dialog.Title className="text-xl font-semibold tracking-tight">
Rename workspace
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-slate-600">
Choose a name your teammates will recognize.
</Dialog.Description>
<form
className="mt-6"
onSubmit={(event) => {
event.preventDefault();
const value = draft.trim();
if (!value) return;
onSave(value);
setOpen(false);
}}
>
<label htmlFor={fieldId} className="text-sm font-medium">
Workspace name
</label>
<input
id={fieldId}
value={draft}
onChange={(event) => setDraft(event.target.value)}
required
maxLength={80}
autoComplete="off"
className="mt-2 block w-full rounded-md border
border-slate-400 px-3 py-2
focus-visible:outline-2
focus-visible:outline-indigo-600"
/>
<div className="rename-actions mt-6">
<Dialog.Close asChild>
<button
type="button"
className={`${button} border border-slate-300`}
>
Cancel
</button>
</Dialog.Close>
<button
type="submit"
disabled={!draft.trim()}
className={`${button} bg-indigo-700 text-white
disabled:cursor-not-allowed disabled:opacity-50`}
>
Save name
</button>
</div>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
Radix supplies role="dialog", modal semantics, and the title/description associations. Adding duplicate ARIA attributes manually would create another source of truth. The input keeps an explicit label because dialog descriptions do not replace field labels.
Motion should not become a layout engine
A production failure pattern worth recognizing is a resize animation that reads scrollHeight, writes height, then measures again on every frame. Interleaving those reads and writes can force repeated layout work. Adding more animation frames does not fix the dependency cycle.
Here is the complete global CSS configuration and micro-interaction layer:
@import "tailwindcss";
.rename-dialog {
container-type: inline-size;
}
.rename-dialog[data-state="open"] {
animation: dialog-enter 140ms ease-out;
}
.rename-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@container (min-width: 24rem) {
.rename-actions {
flex-direction: row;
justify-content: flex-end;
}
}
@keyframes dialog-enter {
from {
opacity: 0;
scale: 0.98;
}
to {
opacity: 1;
scale: 1;
}
}
The animation runs once when data-state="open" appears. No JavaScript intervention. Container queries handle button layout without breakpoints or ResizeObserver polling.
When the contract changes
If the save operation becomes async, this component's contract must expand. The natural boundary is to lift draft and pending state out, then pass onSave: (name: string) => Promise<void> down. The component can then disable both buttons and the input while pending is true, show inline error feedback, and only close on successful resolution.
That separation keeps Radix Dialog responsible for focus and dismissal semantics, but lets the application control when closure is permitted. Disabling the close button during async work is a clear signal; silently ignoring Escape creates invisible modal behavior.
The integration layer that matters
A design system that delivers visual consistency does valuable work. The failure is assuming it also delivers interaction contracts. Focus traps, dismissal behavior, accessible labeling, and state coordination are not styling concerns.
Radix Dialog provides those contracts. Tailwind provides utility composition. The boundary between them is load-bearing. Cross it intentionally or maintain the duplication yourself.
Top comments (1)
The boundary you draw between design intent and interaction guarantees is the part that stuck with me. Have you found that Radix Dialog alone is enough for the focus trap in practice, or do you still need extra checks for screen reader announcements when the draft changes?