tanghoong design
  1. Design system
  2. Overlays

06 — Overlays

Overlays

Modals, drawers, menus, tooltips, toasts and accordions. All of them are built on <dialog> or <details>, so focus trapping, Esc-to-close and the top layer come from the platform.

Why native elements A hand-rolled modal has to reimplement the focus trap, Esc, inert background content, the top layer and the scroll lock — and it usually gets two of those wrong. dialog.showModal() gives you all five, in every current browser.

A native <dialog>. Open it with .showModal(), never by toggling a class — the method is what puts it in the top layer and traps focus. A modal interrupts, so use one only when the task genuinely cannot continue behind it.

Modal
<button class="btn btn--filled" data-open="signin">Sign in</button>

<dialog class="modal" id="signin" aria-labelledby="signin-t">
  <div class="modal__head">
    <div>
      <h2 class="modal__title" id="signin-t">Sign in</h2>
      <p class="modal__desc">Use the address the invite was sent to.</p>
    </div>
    <button class="iconbtn" data-close aria-label="Close">…</button>
  </div>
  <div class="modal__body">…</div>
  <div class="modal__foot">
    <button class="btn btn--quiet"  data-close>Cancel</button>
    <button class="btn btn--filled">Sign in</button>
  </div>
</dialog>

<script>document.getElementById('signin').showModal();</script>

Point aria-labelledby at the title. Give the close button an aria-label — an icon alone has no accessible name.

Drawer

The same <dialog>, pinned to an edge and full height. Use a drawer when the content is a list or a filter set the user will scan against what is behind it; use a modal when they must answer before continuing.

Drawer

A <details> with a positioned list. It opens, closes, toggles on Enter and Space and works with JavaScript off — none of which you get from a div.

Menu
<details class="menu">
  <summary class="btn btn--quiet">Actions</summary>
  <div class="menu__list">
    <a href="/open">Open in new tab</a>
    <div class="menu__sep"></div>
    <button type="button">Delete</button>
  </div>
</details>

Use .menu__list--end to align the panel to the right edge — necessary for anything near the end of a header, or the menu opens off-screen.

The mobile menu — the one behind the button in the top right of this page below 900 px. Also a <details>; nav.js only adds Esc, click-outside and the body scroll lock.

Structure
<header class="site-head">
  …
  <button class="navsheet__trigger" data-navsheet-open aria-label="Menu">
    <svg …></svg>
  </button>
</header>

<!-- A SIBLING of the header. See the warning below. -->
<dialog class="drawer navsheet" data-navsheet aria-label="Site menu">
  <div class="navsheet__panel">
    <div class="navsheet__head">
      <span class="navsheet__title">Site name</span>
      <button class="iconbtn" data-close aria-label="Close menu">…</button>
    </div>
    <a class="sheet-row" href="/work">Work</a>
    <a class="sheet-row" href="/about">About</a>
    <div class="sheet-tools">…</div>
  </div>
</dialog>

The sheet must be a sibling of the header, not a child. .site-head sets backdrop-filter, and any filter makes an element a containing block for its position:fixed descendants — a sheet nested inside it gets trapped in the 56 px header box in the corner instead of covering the viewport. Opening it with showModal() puts it in the top layer, which is outside that containing block entirely.

Tooltip

Decorative only. A tooltip is unreachable on touch and unreliable across screen readers — anything the user actually needs belongs in visible copy or a .hint.

Tooltip
95% of requests finish faster than this

The button carries the real name via aria-label; the bubble is role="presentation" so it is not announced twice.

Toast

A transient confirmation. Use one for something that succeeded and needs no response. Never put an action inside a toast that only exists there — it disappears on a timer.

Toast
<div class="toasts" data-toasts aria-live="polite"></div>

<!-- one toast, appended by script -->
<div class="toast" role="status">
  <span class="toast__icon" aria-hidden="true">●</span>
  <span>Settings saved</span>
</div>

The host is aria-live="polite" and each toast is role="status", so it is announced without interrupting. Errors that need a decision belong in a .note on the page, not a toast.

Accordion

Grouped <details>. Good for an FAQ. Not good for content someone needs to compare across — collapsing that just makes them click more.

Accordion
What does a typical engagement look like?
Two weeks of discovery ending in a written architecture and one working thin slice in production, then a fixed-scope build phase against what discovery found.
Do you work with existing teams?
Usually. The handover matters more than the build — code nobody on the team can maintain is a liability with a delivery date attached.
Which regions?
Malaysia, Singapore and the wider APAC region, remote-first with on-site for discovery where it helps.

Add name="faq" to each <details> to make the group exclusive — opening one closes the others, with no JavaScript.

Do Return focus to the element that opened the overlay when it closes. <dialog> does this for you; a hand-rolled one will not.
Don't Stack overlays. A modal opening another modal means the flow needs a page, not a dialog.
Status
Region