Skip to content
milan.swiss Halbton v0.1.0
Concept project

Components

Modal

A design system that gets dialogs wrong is worse than no design system — everyone assumes the hard part is handled. This is the full keyboard contract, and the page runs the code it documents.

When to use it

To interrupt. A modal is appropriate when continuing without an answer would be worse than breaking someone's flow — confirming a destructive action, or a decision the next step depends on.

When not to

  • Not for anything that could be a page. Modals cannot be linked to, bookmarked, or reached with the back button.
  • Not for long forms. If it scrolls, it wanted to be a page. A dialog that scrolls internally on a phone is a dialog nobody can finish.
  • Never stacked. Opening a modal from a modal breaks focus restoration and leaves people unable to say where they are. This implementation closes any open dialog before opening another.
  • Not for information. Interrupting to say something non-urgent is what a toast or an inline message is for.

Try it

Open it and use only the keyboard. Tab cycles inside and never reaches the page behind. Escape closes it and puts focus back on the button you pressed.

Markup

The component is plain HTML with three data attributes. There is no wrapper component to import and no framework requirement.

markup contract
<button data-ht-modal-open="confirm">Delete project</button>

<div class="ht-modal" id="confirm" hidden>
  <div class="ht-modal__scrim" data-ht-modal-dismiss></div>
  <div class="ht-modal__panel" role="dialog" aria-modal="true"
       aria-labelledby="confirm-title" tabindex="-1">
    <h2 class="ht-modal__title" id="confirm-title">Delete this project?</h2>
    <p class="ht-modal__body">
      This removes the project and its history. It cannot be undone.
    </p>
    <div class="ht-modal__actions">
      <button class="ht-btn ht-btn--secondary ht-btn--md" data-ht-modal-dismiss>Cancel</button>
      <button class="ht-btn ht-btn--critical ht-btn--md" data-ht-modal-dismiss>Delete</button>
    </div>
  </div>
</div>

Keyboard

This is the part systems get wrong, so it is stated explicitly and verified in a browser rather than asserted.

All of this is exercised by the verification script.
Key Result
Tab Cycles forward within the dialog. Never reaches the page behind it.
Shift + Tab Cycles backward, wrapping from the first element to the last.
Escape Closes the dialog and returns focus to the element that opened it.

Accessibility

  • The panel is role="dialog" with aria-modal="true" and is labelled by its own heading.
  • Every sibling of the dialog is made inert while it is open, with an aria-hidden fallback. Applying it to siblings rather than to the body matters — the body contains the dialog.
  • Scroll is locked, and the scrollbar's width is added as padding so the page behind does not shift sideways as it opens.
  • Focus is restored after the page is made interactive again. Restoring while the trigger is still inert silently fails.

Source

components/halbton/lib/modal.mjs
/**
 * Modal dialog.
 *
 * Framework-free, and deliberately so. The Modal documentation page renders
 * this file's source by reading it off disk, and the live demo on that page
 * runs this exact module — so the code shown and the code running are the same
 * bytes. Documenting a vanilla component by shipping a framework
 * reimplementation of it would be the same drift the token pipeline exists to
 * prevent.
 *
 * Markup contract:
 *
 *   <button data-ht-modal-open="my-dialog">Open</button>
 *
 *   <div class="ht-modal" id="my-dialog" hidden>
 *     <div class="ht-modal__scrim" data-ht-modal-dismiss></div>
 *     <div class="ht-modal__panel" role="dialog" aria-modal="true"
 *          aria-labelledby="my-dialog-title" tabindex="-1">
 *       <h2 id="my-dialog-title">…</h2>
 *       …
 *       <button data-ht-modal-dismiss>Cancel</button>
 *     </div>
 *   </div>
 */
import { trapFocus } from "./focus.mjs";

/** The open dialog, if any. Only one can be open at a time, by design. */
let current = null;

function panelOf(root) {
  return root.querySelector("[role='dialog']") ?? root;
}

/**
 * Make everything outside `root` inert, without moving `root` in the DOM.
 *
 * Walking the ancestor chain matters. Marking the children of `<body>` only
 * works if the dialog is a direct child of it — put the markup inside a layout
 * wrapper, as any real page does, and that loop marks the dialog's own
 * ancestor inert. The dialog then cannot be focused, Tab does nothing, and
 * clicks pass through to nothing. It fails silently and looks like a focus-trap
 * bug.
 *
 * Portaling to `<body>` would also work and is what most libraries do, but it
 * moves markup the author wrote and drops whatever CSS context it was in. This
 * keeps the DOM as authored.
 *
 * @param {HTMLElement} root
 * @returns {HTMLElement[]} the elements marked, for release
 */
function inertOutside(root) {
  const marked = [];
  const supportsInert = "inert" in HTMLElement.prototype;
  let node = root;

  while (node.parentElement) {
    for (const sibling of node.parentElement.children) {
      if (sibling === node) continue;
      if (supportsInert) sibling.inert = true;
      else sibling.setAttribute("aria-hidden", "true");
      marked.push(sibling);
    }
    node = node.parentElement;
  }

  return marked;
}

/**
 * Open a dialog.
 * @param {HTMLElement} root the `.ht-modal` container
 */
export function open(root) {
  if (current) close();

  root.hidden = false;

  /* Everything outside the dialog becomes inert, so a screen reader cannot
     wander out of it and a pointer cannot reach it. */
  const marked = inertOutside(root);

  /* Lock scroll without the layout shift that removing the scrollbar causes. */
  const gutter = window.innerWidth - document.documentElement.clientWidth;
  document.body.style.overflow = "hidden";
  if (gutter > 0) document.body.style.paddingRight = `${gutter}px`;

  const panel = panelOf(root);
  const release = trapFocus(panel, { initial: panel });

  const onKeydown = (event) => {
    if (event.key === "Escape") {
      event.stopPropagation();
      close();
    }
  };
  document.addEventListener("keydown", onKeydown);

  current = { root, release, onKeydown, marked };
}

/** Close the open dialog and restore the page. */
export function close() {
  if (!current) return;
  const { root, release, onKeydown, marked } = current;
  current = null;

  document.removeEventListener("keydown", onKeydown);

  for (const el of marked) {
    if ("inert" in HTMLElement.prototype) el.inert = false;
    else el.removeAttribute("aria-hidden");
  }

  document.body.style.overflow = "";
  document.body.style.paddingRight = "";

  root.hidden = true;
  /* Release last: it restores focus to the trigger, which must not happen
     while the page behind is still inert. */
  release();
}

/**
 * Wire every dialog on the page. Delegated, so markup added later still works.
 */
export function init() {
  document.addEventListener("click", (event) => {
    const opener = event.target.closest("[data-ht-modal-open]");
    if (opener) {
      const root = document.getElementById(opener.getAttribute("data-ht-modal-open"));
      if (root) open(root);
      return;
    }

    /* The scrim carries the dismiss attribute too, so a click outside closes.
       `closest` on the event target means a click inside the panel never
       matches, without needing to compare coordinates. */
    if (event.target.closest("[data-ht-modal-dismiss]")) close();
  });
}
components/halbton/lib/focus.mjs
/**
 * Focus containment.
 *
 * One implementation, documented once, used by every component that needs it.
 * A design system that ships two focus traps has already lost the argument for
 * being a system.
 *
 * Framework-free on purpose: the Modal page documents this file by reading it
 * off disk and the demo on that page runs it, so the code shown and the code
 * running are the same bytes.
 */

/**
 * Elements that can hold focus. `:not([tabindex="-1"])` matters — a programmatic
 * focus target is not a tab stop, and treating it as one puts the cycle in the
 * wrong order.
 */
const FOCUSABLE = [
  "a[href]",
  "button:not([disabled])",
  "input:not([disabled]):not([type='hidden'])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  "[tabindex]:not([tabindex='-1'])",
  "audio[controls]",
  "video[controls]",
  "details > summary:first-of-type",
].join(",");

/**
 * Focusable descendants, in tab order, excluding anything not rendered.
 * `offsetParent` is null for `display: none` subtrees; the explicit
 * `visibility` check catches the rest.
 *
 * @param {HTMLElement} root
 * @returns {HTMLElement[]}
 */
export function focusableWithin(root) {
  return Array.from(root.querySelectorAll(FOCUSABLE)).filter((el) => {
    if (el.hasAttribute("disabled") || el.getAttribute("aria-hidden") === "true") return false;
    if (el.offsetParent === null && getComputedStyle(el).position !== "fixed") return false;
    return getComputedStyle(el).visibility !== "hidden";
  });
}

/**
 * Trap Tab within `root` until the returned function is called.
 *
 * Returns a `release` function rather than exposing a stop method, so the
 * caller cannot forget which element it was trapping and release the wrong one.
 *
 * @param {HTMLElement} root
 * @param {{ initial?: HTMLElement | null }} [options]
 * @returns {() => void} release
 */
export function trapFocus(root, options = {}) {
  const previous = document.activeElement;

  const onKeydown = (event) => {
    if (event.key !== "Tab") return;

    const items = focusableWithin(root);
    if (items.length === 0) {
      /* Nothing to move to: hold focus on the container rather than letting
         Tab escape to the page behind. */
      event.preventDefault();
      root.focus();
      return;
    }

    const first = items[0];
    const last = items[items.length - 1];
    const active = document.activeElement;

    /* Focus can start on the container itself, which is not in `items`. Both
       branches below therefore check for "outside the cycle" as well as for
       the specific edge. */
    if (event.shiftKey && (active === first || !root.contains(active))) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && (active === last || !root.contains(active))) {
      event.preventDefault();
      first.focus();
    }
  };

  document.addEventListener("keydown", onKeydown, true);

  const target = options.initial ?? focusableWithin(root)[0] ?? root;
  target.focus();

  return function release() {
    document.removeEventListener("keydown", onKeydown, true);
    /* Focus goes back where it came from. Skipping this is the single most
       common dialog bug: the overlay closes and the keyboard user is returned
       to the top of the document with no idea where they were. */
    if (previous instanceof HTMLElement && document.contains(previous)) {
      previous.focus();
    }
  };
}