Design Lounge
Install

Nº 032 of 520

M3 modal navigation drawer

A 360px Material 3 Expressive modal drawer that slides in under a scrim from a top-app-bar hamburger, with full-width tonal pill for the active item, section headers, dividers and badges.

Preview

Open full size
390 × 844 · 10.9 KB · 136 lines

Use it

Free to use · credit appreciated

1 Once, in your project

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "M3 modal navigation drawer" (piece m3-navigation-drawer) in my stack. Match its brief; restyle it onto my product's theme.

No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.

Brief

242 lines

Build brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.

What it is

A modal navigation drawer for “Nord Post”, a parcel-and-mail app, in Material 3 Expressive on a cool light scheme. A hamburger in the top app bar slides a 360px drawer in from the left over a 40% scrim. The drawer holds a product header, two labelled sections divided by hairlines, nine destinations, count badges and one tertiary “3 today” pill. The active destination is a full-width 56px pill in the secondary container colour — a wide, tonal shape rather than an indicator line. The detail worth copying is the asymmetric timing: 400ms emphasized in, 250ms accelerated out, with focus trapped inside while open and returned to the hamburger on close.

Reference behaviour

  1. Initial state (hero): the drawer is already open (body.open in the markup, hamburger aria-expanded="true") over the inbox: top app bar (surface-low, 118px including the 54px status clearance) with hamburger, “Inbox” title (22px), search and account icons; grouped rows of deliveries and letters with 40px badge tiles behind the scrim. No focus is moved on load.
  2. After closing it (step 6), tap the hamburger (48px round icon button): body.open is set, aria-expanded becomes true. Scrim fades in to rgba(27,27,34,.4) and the drawer translates from −100% to 0 over 400ms cubic-bezier(.2,0,0,1), gaining a soft shadow. Focus moves to the current-page link.
  3. The drawer: 360px wide, rounded 16px on its right corners only, 54px top padding, 34px bottom padding, scrolls internally if needed. Header row: 36px indigo mark “NP”, product name (18px/600), user line (12px), and a close icon button. Then h2 “Mail” with four links, a divider, h2 “Sending” with three links, a divider, two utility links.
  4. Hover a link: a 6% --on-surface state layer appears; pressing raises it to 12%.
  5. Tap a link: it becomes aria-current="page" (pill fill --secondary-c, text --on-secondary-c, weight 600, icon stroke 2.2); the app bar title changes to the link’s label; the drawer closes; a dark snackbar “Opened Deliveries” rises at the bottom for 1.6s.
  6. Tap the scrim, the drawer’s close button or press Esc: drawer translates back to −100% over 250ms cubic-bezier(.3,0,.8,.15), scrim fades on the same clock, focus returns to the element that opened it.
  7. Tab and Shift+Tab cycle only through the close button and the nine links while open.

Structure

390 × 844
┌────────────────────────────────────┐
│ (54px clearance)                   │
│ ≡   Inbox                 ○   ○    │  app bar 64px + 54, surface-low
│ TODAY                              │
│ ┌────────────────────────────────┐ │
│ │[PKG] Parcel from Halden…  09:12│ │  rows in a 20px-radius group
│ │[LTR] Skatteverket         08:30│ │
│ │[PKG] Return label…        07:55│ │
│ └────────────────────────────────┘ │
│ YESTERDAY                          │
│ ┌────────────────────────────────┐ │
│ │ ...                            │ │
│ └────────────────────────────────┘ │
└────────────────────────────────────┘

open state (drawer 360 over a scrim, 30px of scrim visible on the right)
┌───────────────────────────────────╮░┐
│ (54px)                            │░│
│ [NP] Nord Post               ×    │░│  header
│      Karin Ekdahl · Malmö         │░│
│ Mail                              │░│  h2
│ ╭───────────────────────────────╮ │░│
│ │ ▣ Inbox                    24 │ │░│  active: full-width pill 56px
│ ╰───────────────────────────────╯ │░│
│   ⇩ Deliveries          (3 today) │░│  tertiary pill badge
│   ▭ Lockers                       │░│
│   ✎ Drafts                      2 │░│
│ ───────────────────────────────── │░│  divider
│ Sending                           │░│
│   ▭ Buy postage                   │░│
│   ⌖ Find a drop-off               │░│
│   ▤ Address book              118 │░│
│ ───────────────────────────────── │░│
│   ⚙ Settings                      │░│
│   ? Help & feedback               │░│
└───────────────────────────────────╯░┘
  • <header class="appbar"> — hamburger <button aria-expanded aria-controls="drawer">, <h1>, two icon buttons.
  • <main> — .eyebrow labels and .rows groups of <button class="row"> (badge tile, title/subtitle, time).
  • <button class="scrim" tabindex="-1"> — inset:0.
  • <nav class="drawer" id="drawer" aria-label="Main"> — .head, then h2 + <ul> of <a> per section, <hr> dividers. Each <a>: 24px SVG, label text, optional .n count or .pill badge.
  • <div class="pop" role="status" aria-live="polite"> — snackbar.

Tokens

:root {
  /* tonal surfaces — cool lavender bias */
  --surface: #fbf8ff;
  --surface-low: #f4f1fb;        /* app bar, drawer, row groups */
  --surface-c: #ede9f6;
  --surface-high: #e7e3f0;       /* hover on rows and icon buttons */
  --surface-highest: #e1ddea;
  --on-surface: #1b1b22;
  --on-surface-v: #484556;       /* drawer link text, section headers */
  --outline: #787487;            /* timestamps */
  --outline-v: #c8c4d5;          /* dividers */

  /* primary / secondary / tertiary */
  --primary: #4a4ab8;            /* mark, focus rings */
  --on-primary: #ffffff;
  --secondary-c: #dfe1ff;        /* active pill, PKG badge tile */
  --on-secondary-c: #121a5c;
  --tertiary: #7a4f7d;           /* "3 today" pill */
  --tertiary-c: #fed6ff;         /* LTR badge tile */
  --on-tertiary-c: #3d1a42;

  --scrim: rgba(27, 27, 34, .4);
  --state-hover: .06;            /* opacity of --on-surface layer */
  --state-press: .12;

  /* type */
  --font: "Roboto Flex", system-ui, sans-serif;
  --fs-title: 22px; --fs-brand: 18px; --fs-body: 15px; --fs-h2: 14px; --fs-count: 13px; --fs-meta: 12px; --fs-pill: 11px;

  /* layout / shape */
  --drawer-w: 360px;
  --r-drawer: 0 16px 16px 0;
  --r-item: 999px;
  --item-h: 56px;
  --r-group: 20px;
  --r-row: 14px;
  --r-tile: 12px;
  --r-snack: 12px;

  /* spacing */
  --s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px;

  /* elevation */
  --shadow-1: 0 1px 3px rgba(27,27,34,.12), 0 6px 24px rgba(27,27,34,.10);

  /* motion */
  --t-micro: 160ms;
  --t-open: 400ms;
  --t-close: 250ms;
  --t-snack: 1600ms;
  --ease-emph: cubic-bezier(.2, 0, 0, 1);
  --ease-exit: cubic-bezier(.3, 0, .8, .15);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
App bar titleRoboto Flex22px5001.2−0.01emsentence
Drawer brandRoboto Flex18px6001.10sentence
Drawer user lineRoboto Flex12px4001.40sentence
Section headerRoboto Flex14px6001.30sentence
Drawer linkRoboto Flex15px500 (active 600)10sentence
Count badgeRoboto Flex13px60010numerals
Tertiary pillRoboto Flex11px7001.40sentence
List eyebrowRoboto Flex12px6001.3+0.08emUPPERCASE
Row titleRoboto Flex15px5001.350sentence
Row subtitleRoboto Flex13px4001.40sentence
Badge tileRoboto Flex11px7001+0.04emUPPERCASE
SnackbarRoboto Flex14px5001.40sentence

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.draweropentranslateX−100% → 0400ms--ease-emphshadow appears at the same time
.drawerclosetranslateX0 → −100%250ms--ease-exit
.scrimopenopacity0 → 1400ms--ease-emph
.scrimcloseopacity1 → 0250ms--ease-exit
link state layerhover / pressopacity0 → .06 / .12160mslinear::before pseudo
active pillselectbackground, colortransparent → secondary-c160mslinearno movement
.popselectopacity, translateY0, 12px → 1, 0160ms--ease-emphauto-hides after 1600ms
.iconhoverbackgroundtransparent → surface-high160mslinear

Reduced motion: all transitions 1ms. The drawer appears and disappears instantly; the scrim still shows.

States

  • Closed: drawer visibility:hidden; transform:translateX(-100%); scrim opacity:0; pointer-events:none.
  • Open: body.open; drawer visible with --shadow-1; scrim interactive; hamburger aria-expanded="true".
  • Link hover: 6% on-surface layer. Link pressed: 12%.
  • Link current: aria-current="page", pill --secondary-c, text --on-secondary-c, weight 600, icon stroke 2.2px, count badge takes the same text colour.
  • Focus-visible (all controls): 3px --primary outline, −3px offset (inside the pill so it isn’t clipped by overflow:hidden).
  • Row hover: background --surface-high.
  • Snackbar showing: .pop.on.

Accessibility

  • Hamburger: <button aria-label="Open navigation" aria-expanded aria-controls="drawer">.
  • Drawer: <nav aria-label="Main">; sections use <h2> headings; destinations are <a> with aria-current="page" on the active one.
  • Focus trap while open: Tab from the last link wraps to the close button; Shift+Tab from the close button wraps to the last link. Esc closes. On open, focus goes to the current link; on close it returns to whatever opened the drawer.
  • The scrim button has tabindex="-1" and an aria-label, so pointer users can close it without adding a tab stop.
  • Snackbar is role="status" aria-live="polite".
  • Contrast: --on-surface-v on --surface-low 9.1:1; --on-secondary-c on --secondary-c 11.7:1; white on --tertiary 6.4:1.
  • Hit targets: icon buttons 48px, drawer links 56px, rows ≥ 64px.

Responsive rules

  • 390 wide: drawer 360px, 30px of scrim visible.
  • 360 wide: drawer becomes min(360px, 100% - 24px) = 336px so the scrim edge stays tappable.
  • ≥ 840 wide: switch to a standard (non-modal) drawer: no scrim, no translate, position:static, 360px column beside the content, hamburger hidden.
  • Short viewports: the drawer scrolls internally (overflow-y:auto); header stays at the top of the scroll content, not sticky.

Acceptance checklist

  • Drawer is exactly 360px wide with border-radius: 0 16px 16px 0 and #F4F1FB background.
  • Opening translates from −100% to 0 over 400ms cubic-bezier(.2,0,0,1); closing takes 250ms cubic-bezier(.3,0,.8,.15).
  • Scrim is rgba(27,27,34,.4) and closes the drawer on tap.
  • Active item is a full-width 56px pill filled #DFE1FF with #121A5C text at weight 600.
  • Two <h2> section headers and two 1px #C8C4D5 dividers separate the groups.
  • Count badges (“24”, “2”, “118”) right-align at 13px/600; “3 today” is a #7A4F7D pill with white 11px/700 text.
  • Hamburger aria-expanded mirrors the state; Esc closes; focus returns to the hamburger on close.
  • Tab is trapped to the close button + nine links while open.
  • Selecting a destination updates the app bar title, closes the drawer and shows a snackbar for 1.6s.
  • Hover shows a 6% state layer, press 12%, on drawer links.
  • No fixed control sits within the top 54px; drawer content starts below it.
  • The first frame shows the drawer open; closing and reopening replays the slide.
  • Reduced motion collapses all transitions to 1ms.

Implementation notes

Slide with transform and keep it out of the tab order when closed. visibility toggles on the same transition so the closed drawer isn’t focusable, but the slide still renders:

.drawer { position:absolute; top:0; bottom:0; left:0; width:360px;
  transform:translateX(-100%); visibility:hidden;
  transition: transform 250ms var(--ease-exit), visibility 0s linear 250ms; }
.open .drawer { transform:none; visibility:visible; box-shadow:var(--shadow-1);
  transition: transform 400ms var(--ease-emph), visibility 0s; }

State layers as a pseudo-element so hover/press never change the pill’s own colour:

.drawer a { position:relative; overflow:hidden; border-radius:999px; height:56px; }
.drawer a::before { content:""; position:absolute; inset:0; background:var(--on-surface); opacity:0; transition:opacity 160ms; }
.drawer a:hover::before  { opacity:.06 }
.drawer a:active::before { opacity:.12 }
.drawer a[aria-current="page"] { background:var(--secondary-c); color:var(--on-secondary-c); font-weight:600; }

Minimal focus trap — only the close button and the links are focusable inside:

addEventListener('keydown', (e) => {
  if (!body.classList.contains('open')) return;
  if (e.key === 'Escape') { e.preventDefault(); setOpen(false); return; }
  if (e.key !== 'Tab') return;
  const f = [closeBtn, ...links], i = f.indexOf(document.activeElement);
  if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
  else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
});

Common mistakes: animating left instead of transform; giving the drawer full-height rounded corners on the left (only the right edge rounds); using an inset indicator bar for the active item (M3 Expressive uses a full-width tonal pill); forgetting outline-offset:-3px so the focus ring is clipped by the pill’s overflow:hidden.

Details

Palette
Type
Roboto Flex
Motion
Subtle motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

60fps

Show one small moment at a time, and let the tags do the sorting.

All sources

On the shelves