Stylish dialogs | Fractaled Mind

8 min read Original article ↗

Campsite has some of my favorite UI styling on the web. Naturally, I cracked open their source hoping to learn something. What I found: React components rendering <div>s inside <div>s, with piles of JavaScript doing what <dialog> does for free.

So I borrowed their visual design and rebuilt it with semantic HTML and CSS using affordance classes. I want to walk you through all of the choices I’ve made and how it all comes together.


The HTML

Here’s the markup structure I use for a full-featured dialog:

<dialog id="example-dialog" class="ui/dialog"

aria-labelledby="example-dialog-title"

aria-describedby="example-dialog-desc"

closedby="any">

<header>

<hgroup>

<h2 id="example-dialog-title">Basic Dialog</h2>

<p id="example-dialog-desc">This is a basic dialog with header, content, and footer sections.</p>

</hgroup>

<button type="button" class="ui/button/plain aspect-square"

commandfor="example-dialog" command="close"

aria-label="Close dialog">&times;</button>

</header>

<form method="POST" action="#">

<article>

<p>

Dialog content goes here. This area can contain forms, text, images,

or any other content. The native <code>&lt;dialog&gt;</code> element

handles focus management and accessibility automatically.

</p>

</article>

<footer>

<button class="ui/button/flat" type="submit"

formmethod="dialog" formnovalidate value="cancel">Cancel</button>

<button class="ui/button/primary" type="submit" autofocus>Confirm</button>

</footer>

</form>

</dialog>

Semantic Elements

Yes, I’m a semantic HTML nerd. <header>, <article>, <footer> instead of <div>s everywhere. The structure is obvious when you revisit the code six months later, and you can target these elements directly in CSS without inventing class names.

The Form Wrapper

The body and footer are wrapped in a <form>. This might seem odd at first, but it unlocks key functionality. Dialogs often need to do something—create a resource, update settings, submit data. Wrapping in a form means your dialog is ready for that from the start. And for simple confirmations, method=dialog on the form (or formmethod=dialog on a button) closes the dialog without any network request.

Two Close Mechanisms

The header’s × button uses command=close because it’s outside the form. The footer’s Cancel button uses formmethod=dialog because it’s inside—a submit that closes without hitting the network.

Focus Handling

The confirm button has autofocus. When the dialog opens, focus moves there immediately—keyboard users land on the primary action, one Enter key away.

Light Dismiss

The closedby=any attribute enables “light dismiss”—clicking the backdrop closes the dialog. Combined with the browser’s built-in Escape key handling, users have multiple intuitive ways to close. No JavaScript event listeners required.

Accessibility

The aria-labelledby and aria-describedby attributes connect the dialog to its heading and description. Screen readers announce both immediately when the dialog opens, giving users full context before they need to act.

For confirmation dialogs specifically, add role=alertdialog. This signals that the dialog communicates an important message requiring a user response—distinct from a generic dialog that might just display information or offer a form. The browser and assistive technologies treat alert dialogs with appropriate urgency.

<dialog id="confirm-delete"

role="alertdialog"

aria-labelledby="confirm-title"

aria-describedby="confirm-desc">

<!-- ... -->

</dialog>


The CSS Architecture

The styles use Tailwind v4’s @utility directive to create tree-shakeable, autocomplete-friendly utility classes. Here’s the structure:

@import "tailwindcss";

@theme {

--shadow-dialog: 0px 0px 3.5px rgba(0, 0, 0, 0.04),

0px 0px 10px rgba(0, 0, 0, 0.04),

0px 0px 24px rgba(0, 0, 0, 0.05),

0px 0px 80px rgba(0, 0, 0, 0.08);

--shadow-dialog-dark: inset 0 0.5px 0 rgb(255 255 255 / 0.08),

inset 0 0 1px rgb(255 255 255 / 0.24),

0 0 0 0.5px rgb(0 0 0 / 1),

0px 0px 4px rgba(0, 0, 0, 0.08),

0px 0px 10px rgba(0, 0, 0, 0.12),

0px 0px 24px rgba(0, 0, 0, 0.16),

0px 0px 80px rgba(0, 0, 0, 0.2);

}

I borrowed these layered shadows directly from Campsite. The multiple shadows at different blur radii create a more natural, ambient lighting effect—the kind that makes people think you hired a designer. In dark mode, inset shadows add an inner glow that gives the panel depth.


The Base Dialog Utility

@utility ui/dialog {

:where(&) {

@apply rounded-lg border-none bg-white p-0 text-zinc-900 shadow-dialog;

@apply isolate flex w-full flex-col;

@apply max-w-[calc(100vw-32px)] min-w-sm;

@apply pointer-events-none invisible;

@apply m-auto;

@apply max-h-[calc(100dvh-env(safe-area-inset-bottom,0)-env(safe-area-inset-top,0)-32px)];

@variant sm {

@apply max-w-md;

}

@variant focus {

@apply outline-0;

}

@variant open {

@apply pointer-events-auto visible;

}

@variant dark {

@apply bg-zinc-900 text-zinc-50 shadow-dialog-dark;

}

}

}

The Visibility Problem

A <dialog> with display: flex stays visible even when closed—the browser’s default display: none gets overridden. The fix: pointer-events-none and invisible. The dialog stays in the DOM but users can’t see or interact with it. When [open] applies, we flip both back. This also prevents a flash of dialog content on page load.

Sizing Constraints

The max-w-[calc(100vw-32px)] ensures the dialog never touches the screen edges—always 16px of breathing room on each side. The min-w-sm (24rem) prevents the dialog from becoming uncomfortably narrow on larger screens.

For height, max-h-[calc(100dvh-env(safe-area-inset-bottom,0)-env(safe-area-inset-top,0)-32px)] does more work. The dvh unit (dynamic viewport height) accounts for mobile browser chrome that appears and disappears. The env(safe-area-inset-*) functions respect the notch and home indicator on modern phones. Together, they ensure the dialog fits the actual available space, not just the theoretical viewport.

Stacking Context

The isolate class creates a new stacking context. Any z-index values inside the dialog stay contained—dropdowns or tooltips won’t escape and interfere with elements outside.

Why focus Instead of focus-visible

The focus variant removes the outline entirely. You might expect :focus-visible here, but autofocus on dialog buttons triggers :focus, not :focus-visible. If you only style focus-visible, autofocused elements remain unstyled.


Slot Classes and Semantic Selectors

Now for a part I’m quite pleased with. We define independent “slot” utilities that can apply to any element:

@utility dialog/header {

:where(&) {

@apply relative flex-none rounded-t-lg p-4 text-sm;

&:has(> button[command="close"]) {

@apply pr-12;

}

}

}

@utility dialog/title {

:where(&) {

@apply m-0 flex-1 font-semibold;

}

}

@utility dialog/description {

:where(&) {

@apply m-0 mt-0.5 text-zinc-600;

@variant dark {

@apply text-zinc-300;

}

}

}

@utility dialog/content {

:where(&) {

@apply flex flex-1 flex-col overflow-y-auto p-4 pt-0 text-sm;

}

}

@utility dialog/footer {

:where(&) {

@apply flex items-center rounded-b-lg border-t border-black/10 p-3;

@variant dark {

@apply border-white/12;

}

}

}

Then the parent ui/dialog utility applies these to semantic elements automatically:

@utility ui/dialog {

/* ... base styles ... */

:where(& > header) {

@apply dialog/header;

}

:where(& header hgroup :is(h1, h2, h3, h4, h5, h6)) {

@apply dialog/title;

}

:where(& header hgroup p) {

@apply dialog/description;

}

:where(& form > article) {

@apply dialog/content;

}

:where(& form > footer) {

@apply dialog/footer;

}

}

Write semantic HTML, get automatic styling. Or apply dialog/content directly to a <div> when your framework generates custom markup.

The :where() wrapper keeps specificity at zero. Without it, these nested selectors would have higher specificity than single utility classes, and you’d be fighting your own styles every time you needed to customize something.


Animations

Most dialog implementations just fade in and out. That’s fine, but we can do better.

@keyframes dialog-slide-up-scale-fade {

from {

opacity: 0;

transform: translateY(20px) scale(0.98);

}

to {

opacity: 1;

transform: translateY(0) scale(1);

}

}

@keyframes dialog-scale-down-fade {

from {

opacity: 1;

transform: translateY(0) scale(1);

}

to {

opacity: 0;

transform: translateY(0) scale(0.95);

}

}

Asymmetric Motion

Entry slides up and scales in. Exit just scales down and fades. Sliding down on exit felt wrong—it implies the dialog is going somewhere, but it’s not. It’s disappearing. Scale-down-and-fade says “dismissed” without false movement.

Timing Differences

@utility ui/dialog {

--dialog-entry-duration: 0.2s;

--dialog-exit-duration: calc(var(--dialog-entry-duration) * 0.75);

--backdrop-entry-duration: calc(var(--dialog-entry-duration) * 0.2);

--backdrop-exit-duration: calc(var(--dialog-exit-duration) * 0.75);

}

Exit animations run at 75% the duration of entry. Entrances should feel intentional; exits should get out of the way.

The backdrop animates even faster. On entry, it appears almost instantly (20% of dialog duration), then the dialog follows—the dialog emerges from the dimmed background rather than appearing on top of it. On exit, the backdrop fades before the dialog finishes so you never see the dialog floating against a fully-bright background.

The Technical Details

@utility ui/dialog {

animation: dialog-scale-down-fade var(--dialog-exit-duration) var(--dialog-easing) forwards;

transition:

overlay var(--dialog-exit-duration) var(--dialog-easing) allow-discrete,

display var(--dialog-exit-duration) var(--dialog-easing) allow-discrete;

@variant open {

animation: dialog-slide-up-scale-fade var(--dialog-entry-duration) var(--dialog-easing) forwards;

@starting-style {

animation: none;

}

}

}

The allow-discrete keyword on display and overlay is essential. These are discrete properties—they can’t interpolate between values. The keyword tells the browser to keep the element visible during the exit animation, only flipping to display: none after the animation completes. Without it, your exit animation just… doesn’t happen. The dialog vanishes instantly.

The @starting-style rule defines where the animation begins. Without it, the browser renders the dialog immediately in its final state. Same problem, opposite direction—no entry animation.


Button Styles

The demo includes button styles, but those deserve their own post. Coming soon.


Browser Support

Feature Chrome Safari Firefox
command/commandfor 135+ 26.2+ 144+
@starting-style 117+ 17.5+ 129+
closedby 134+ Not yet 141+
allow-discrete 117+ 17.4+ 129+

For production today, you can use polyfills if needed:


Interactive Demo

Here’s a working demo. Try opening it, then close it different ways: click the × button, click Cancel, click Confirm, press Escape, or click the backdrop.

Want to experiment? Explore the full demo on Tailwind Play where you can tweak the styles and see how everything fits together.


What’s Next

Dialogs are just one piece. I’m building out a full set of affordance classes—buttons, forms, menus, popovers, tabs, tables. Designer-quality styling, browser-native behavior. The kind of UI that makes people ask who you hired. So be on the lookout for more like this 👀.