Accessibility Developer Kit · Guide 10 / 20

Accessible Dialog (Non-Modal)

Side drawers, slide-overs, popovers, bottom sheets. All have role="dialog", Escape to close, and return focus — but no focus trap, no aria-modal, and no inert background.

  • WCAG: 2.1.1 · 2.1.2 · 2.4.3 · 4.1.2
  • Level: Intermediate
  • Icons: lucide-react (X, HelpCircle, ShoppingBag)
1

What we are building

A dialog that lives alongside the page instead of taking it over.

What we are building

A non-modal dialog is a role="dialog" that does not block interaction with the rest of the page. Think cart drawer, filter panel, color picker popover, help bubble, mobile share sheet. Users can keep working behind it.

It still gets: an accessible name, Escape to close, focus moving in on open and back to the trigger on close. But it does not get: aria-modal, a focus trap, or inert on the background.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Side drawer (cart)

Preferred
Live example

Fake shop (stays interactive when drawer is open):

BrowseDeals

Tab out of the drawer — you can still interact with the shop. That is non-modal.

HTML · Accessible
<!-- Non-modal drawer: no aria-modal, no focus trap, no inert -->
<aside
  role="dialog"
  aria-labelledby="cart-title"
  id="cart-drawer"
  hidden
>
  <h2 id="cart-title">Your cart</h2>
  <!-- items -->
  <button type="button" aria-label="Close cart">×</button>
</aside>

<!-- JS: Escape closes + returns focus; background stays fully interactive -->

A cart or filter drawer. Non-modal — Tab can still reach the shop behind. Escape closes.

Variant B

Modal vs Dialog decision

Decision guide
Live example
RuleModalDialog
Blocks background interactionYesNo
Focus trapYesNo
role="dialog"YesYes
aria-modal="true"YesNo / "false"
inert backgroundYesNo
Escape closesYesYes
Return focus to triggerYesYes
ExampleConfirm deleteCart drawer, filter panel

Pick modal when the user MUST decide before continuing. Pick dialog when they can keep working alongside it.

HTML · Accessible
<!-- If it must coexist with the page, it's a non-modal dialog -->
<aside
  role="dialog"
  aria-labelledby="filter-title"
>
  <h2 id="filter-title">Filters</h2>
  <!-- filter form -->
  <button type="button">Apply</button>
  <button type="button">Reset</button>
</aside>

<!-- Rule of thumb:
     - MUST block? → modal (aria-modal="true", focus trap, inert).
     - Can coexist? → dialog (aria-modal absent, no trap, no inert).
-->

If users MUST decide before continuing → modal. If they can coexist with the page → dialog.

Variant C

Popover dialog (help bubble)

Lightweight
Live example

What is a billing cycle?

Non-modal popover anchored to its trigger. Esc or click outside closes.

HTML · Accessible
<!-- Non-modal popover dialog anchored to the trigger -->
<button
  type="button"
  aria-label="More information about billing cycles"
  aria-haspopup="dialog"
  aria-expanded="false"
  aria-controls="billing-pop"
>?</button>

<div
  id="billing-pop"
  role="dialog"
  aria-labelledby="billing-pop-title"
  hidden
  class="popover"
>
  <h4 id="billing-pop-title">Billing cycle</h4>
  <p>Your plan renews every 30 days...</p>
  <button type="button">Got it</button>
</div>

<!-- JS: Escape closes, outside click closes, focus returns to trigger -->

A trigger-anchored dialog. Esc + outside click dismiss. Use for help, pickers, color/emoji panels.

Variant D

Bottom sheet (mobile)

Mobile pattern
Live example
Article

Lorem ipsum dolor sit amet, consectetur adipiscing elit.

Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

Mobile bottom sheet — slides up from the bottom, non-modal.

HTML · Accessible
<!-- Mobile bottom sheet as a non-modal dialog -->
<button
  type="button"
  aria-haspopup="dialog"
  aria-expanded="false"
  aria-controls="share-sheet"
>Share</button>

<aside
  id="share-sheet"
  role="dialog"
  aria-labelledby="share-title"
  hidden
  class="sheet"
>
  <h3 id="share-title">Share this article</h3>
  <button>Copy link</button>
  <button>Email</button>
  <button>Twitter / X</button>
</aside>

Mobile share / action sheet. Non-modal when it offers choices rather than demanding one.

3

Flagship implementation

Reusable non-modal AccessibleDialog with Escape + return-focus.

Flagship implementation

AccessibleDialog.jsx
import React, { useEffect, useRef } from "react";
import "./AccessibleDialog.css";

/**
 * AccessibleDialog (non-modal)
 *
 *  - open, onClose: controlled state
 *  - title: heading text (required)
 *  - triggerRef: ref of the element that opened the dialog (for return focus)
 *  - position: "right" | "left" | "bottom" (default "right")
 */
export default function AccessibleDialog({
  open, onClose, title, triggerRef,
  position = "right",
  children,
}) {
  const panelRef = useRef(null);

  useEffect(() => {
    if (open) {
      panelRef.current?.querySelector("button, a, input")?.focus();
    } else {
      triggerRef?.current?.focus();
    }
  }, [open, triggerRef]);

  useEffect(() => {
    if (!open) return;
    function onKey(e) {
      if (e.key === "Escape") { e.preventDefault(); onClose(); }
      // No Tab trap — non-modal.
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open, onClose]);

  if (!open) return null;

  return (
    <aside
      ref={panelRef}
      role="dialog"
      aria-labelledby="dialog-title"
      className={`dialog dialog--${position}`}
    >
      <header className="dialog__head">
        <h2 id="dialog-title">{title}</h2>
        <button
          type="button"
          aria-label={`Close ${title}`}
          onClick={onClose}
        >×</button>
      </header>
      {children}
    </aside>
  );
}
Usage · Shop.jsx
import { useRef, useState } from "react";
import AccessibleDialog from "./AccessibleDialog";

export default function Shop() {
  const [open, setOpen] = useState(false);
  const btnRef = useRef(null);

  return (
    <>
      <header>
        <button
          ref={btnRef}
          type="button"
          aria-haspopup="dialog"
          aria-expanded={open}
          aria-controls="cart-drawer"
          onClick={() => setOpen(true)}
        >
          Cart (3)
        </button>
      </header>

      <main>
        {/* Shop content stays interactive while drawer is open */}
      </main>

      <AccessibleDialog
        open={open}
        onClose={() => setOpen(false)}
        title="Your cart"
        triggerRef={btnRef}
      >
        <ul>
          <li>Headphones &mdash; $120</li>
          <li>USB-C cable &mdash; $12</li>
        </ul>
        <button type="button">Checkout</button>
      </AccessibleDialog>
    </>
  );
}
4

Keyboard behavior

Non-modal dialogs let the keyboard roam the page.

Keyboard behavior

ActionExpected result
OpenFocus moves into the dialog
TabMoves to next element — can leave the dialog (no trap)
EscCloses dialog; focus returns to trigger
Click outside (popover)Closes popover-style dialogs
5

Screen reader expectations

Dialog is announced; user is free to move on.

Screen reader expectations

  • On open: “Your cart, dialog” (no “modal” word)
  • Inside: SR navigates dialog content, then can continue through the page
  • Trigger: “Cart, has pop-up, dialog, expanded, button”
  • After close: SR focus is back on the trigger, announcing its label
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
2.1.1 Keyboard (A)Dialog must be fully operable with keyboard.
2.1.2 No Keyboard Trap (A)Users MUST be able to Tab out — do not trap.
2.4.3 Focus Order (A)Focus moves in on open, returns to trigger on close.
4.1.2 Name, Role, Value (A)role=dialog + accessible name via aria-labelledby.
1.4.11 Non-text Contrast (AA)Panel border / shadow must have 3:1 contrast with underlying content.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: open → assert role=dialog + name → assert NO aria-modal → assert Tab leaves → Escape closes → focus returns.

Jest + Testing Library + jest-axe

AccessibleDialog.test.jsx
// Jest + @testing-library/react + jest-axe
import { useRef, useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleDialog from "./AccessibleDialog";

expect.extend(toHaveNoViolations);

function Fixture() {
  const [open, setOpen] = useState(false);
  const btnRef = useRef(null);
  return (
    <>
      <button ref={btnRef} onClick={() => setOpen(true)}>Open</button>
      <AccessibleDialog
        open={open}
        onClose={() => setOpen(false)}
        title="Cart"
        triggerRef={btnRef}
      >
        <button>Checkout</button>
      </AccessibleDialog>
      <a href="#elsewhere">Outside link</a>
    </>
  );
}

test("exposes role=dialog with accessible name but NOT aria-modal", async () => {
  const user = userEvent.setup();
  render(<Fixture />);
  await user.click(screen.getByRole("button", { name: "Open" }));
  const dlg = screen.getByRole("dialog", { name: "Cart" });
  expect(dlg).toBeInTheDocument();
  expect(dlg).not.toHaveAttribute("aria-modal", "true");
});

test("Tab leaves the dialog (non-modal — no trap)", async () => {
  const user = userEvent.setup();
  render(<Fixture />);
  await user.click(screen.getByRole("button", { name: "Open" }));
  const checkout = screen.getByRole("button", { name: "Checkout" });
  const outside = screen.getByRole("link", { name: "Outside link" });
  checkout.focus();
  // Jump past the close button, then into the page:
  await user.tab(); // close button
  await user.tab(); // trigger (Open)
  await user.tab(); // outside link
  expect(outside).toHaveFocus();
});

test("has no axe violations", async () => {
  const { container } = render(<Fixture />);
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleDialog.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleDialog from "./AccessibleDialog";

test("Escape closes the dialog", async () => {
  const user = userEvent.setup();
  function F() {
    const [open, setOpen] = useState(true);
    return (
      <AccessibleDialog open={open} onClose={() => setOpen(false)} title="X">
        <button>OK</button>
      </AccessibleDialog>
    );
  }
  render(<F />);
  await user.keyboard("{Escape}");
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

Playwright + @axe-core/playwright

dialog.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("dialog page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-dialog");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("drawer opens, Escape closes, focus returns to trigger", async ({ page }) => {
  await page.goto("/en/accessible-dialog");
  const trigger = page.getByRole("button", { name: /cart/i });
  await trigger.click();
  await expect(page.getByRole("dialog", { name: /your cart/i })).toBeVisible();
  await page.keyboard.press("Escape");
  await expect(trigger).toBeFocused();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Container has role="dialog"
  • aria-modal is NOT set (or set to "false")
  • Accessible name via aria-labelledby (points at the heading)
  • Focus moves into the dialog on open
  • Tab can leave the dialog (no focus trap)
  • Background remains interactive
  • Escape closes the dialog
  • Focus returns to the trigger button on close
  • Trigger has aria-haspopup="dialog" + aria-expanded + aria-controls
  • Zero critical / serious axe violations
9

Common mistakes

The non-modal dialog bugs we see on nearly every audit.

Common mistakes

  • Copy-pasting modal code. Focus trap + inert make a non-modal into a de-facto modal.
  • Setting aria-modal="true". Tells SR the page is blocked when it isn’t.
  • No aria-haspopup="dialog" on trigger. SR users have no hint what pressing it does.
  • No Escape handler. Keyboard users have to find the X.
  • Popover closes on all outside clicks — including on anchor itself. Verify the trigger is excluded from outside-click.
  • Mobile sheet with no visible close button. Not every user swipes.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo