Skip to content

Drawer

hc-drawer styles a native <dialog> element as a panel that slides in from any edge of the viewport. The native dialog handles focus trapping, Escape-to-close, and the ::backdrop layer for free; HC adds the edge positioning, the slide-in / slide-out animation, and (via installDrawer) the backdrop-click-to-close affordance users expect from a slide panel.

PrimitiveRequired version
HTML <dialog> + showModal()All evergreen browsers
CSS @starting-style + transition-behavior: allow-discreteChrome 117+, Firefox 129+, Safari 17.5+

The slide animation degrades gracefully — older browsers without @starting-style snap to the final position without the entry transition, but the drawer is still functional.

Settings

Form fields, settings, anything.

import { installDrawer } from '@hypermedia-components/core';
installDrawer();

data-side accepts right (default), left, top, and bottom. Right/left drawers fill the viewport height and cap their width at --hc-drawer-side-max-width; top/bottom drawers fill the viewport width and cap their height at --hc-drawer-vert-max-height.

<dialog class="hc-drawer" data-side="right"></dialog>
<dialog class="hc-drawer" data-side="left"></dialog>
<dialog class="hc-drawer" data-side="top"></dialog>
<dialog class="hc-drawer" data-side="bottom"></dialog>

Three idiomatic patterns, in order of preference:

A button inside a form whose method="dialog" closes the dialog on submit. Native, accessible, declarative.

<form method="dialog">
<button class="hc-button" type="submit">Cancel</button>
</form>

Clicking outside the drawer panel (on the ::backdrop area) closes the dialog. The behavior detects this via event.target === dialog.

Drag the panel toward its anchored edge to dismiss it. The axis follows data-side (right / left → horizontal, top / bottom → vertical); past ~40% of the panel size, or with a quick flick, the drawer slides out and closes — release short of that and it snaps back. Only the outward direction moves (an inward drag is clamped, so there’s no rubber-banding and prefers-reduced-motion needs no special case).

The gesture is grabbed from the panel chrome — the __header / __footer — never the scrollable __body or an interactive control, so content scrolling and buttons keep working. Pointer-Events based, so it works for mouse, touch, and pen.

document.getElementById('settings-drawer').close();

Useful from htmx response handlers — pair with installCloseDialog for the htmx-success-closes-the-dialog pattern.

Open the drawer in response to a server fetch and let htmx swap content into it:

<button class="hc-button"
data-hx-get="/users/42/edit"
data-hx-target="#user-drawer .hc-drawer__body"
data-hx-swap="innerHTML"
onclick="document.getElementById('user-drawer').showModal()">
Edit user
</button>
<dialog class="hc-drawer" id="user-drawer" data-side="right">
<header class="hc-drawer__header">
<h2 class="hc-drawer__title">Edit user</h2>
<form method="dialog">
<button class="hc-button" data-variant="ghost" data-size="sm"
type="submit" aria-label="Close">×</button>
</form>
</header>
<div class="hc-drawer__body">
<hc-spinner></hc-spinner>
</div>
</dialog>

For close-on-success after a form save:

<form
data-hx-post="/users/42"
data-hx-target="this"
data-hc-close-dialog-on-success>
</form>

The bundled installCloseDialog behavior closes the enclosing <dialog> on a successful htmx response. Same pattern as hc-dialog.

  • The native <dialog> already exposes the right role, focus trap, and Escape semantics. Don’t add role="dialog" or aria-modal="true" — they are redundant and can confuse some screen readers.
  • Always include a focusable element inside the drawer so focus trapping has something to land on. A close button in the header or a primary action in the footer is plenty.
  • Provide a visible close affordance. The <form method="dialog"> pattern shown above pairs the visible ”×” button with a built-in keyboard activation path (Enter / Space on the button).
  • Slide animations respect prefers-reduced-motion: reduce — transition duration drops to 0 ms when the user has opted out.

The drawer ships with one visual style intentionally — the surface follows the same conventions as hc-dialog, so applying the same theming tokens (light / dark / color) Just Works. Variants like data-variant="error" for destructive flows can be modelled with content (a red header, an error alert at the top) rather than the container chrome.

Component tokens (in component.tokens.json):

Token pathPurpose
drawer.bg / fg / borderSurface colors.
drawer.side-max-widthCap on the right / left drawer width.
drawer.vert-max-heightCap on the top / bottom drawer height.
drawer.paddingInner padding for header / body / footer.
drawer.gapHeader / footer flex gap.
drawer.backdrop::backdrop background.
drawer.durationSlide animation duration.
Show the generated CSS variables
--hc-drawer-bg | -fg | -border
--hc-drawer-side-max-width | -vert-max-height
--hc-drawer-padding | -gap
--hc-drawer-backdrop | -duration
  • Dialog — centred modal sibling.
  • Popover — for transient floating content.