Design Lounge
Install

Nº 033 of 520

M3 search bar morph

A Material 3 Expressive container transform: a 56px pill search bar in a dark top app bar grows into a full-screen search view (radius 28→0) with suggestions fading in; the back arrow reverses it.

Preview

Open full size
390 × 844 · 10.0 KB · 115 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 search bar morph" (piece m3-search-bar-morph) 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

216 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 search bar → search view container transform for “Fjord Bank”, a mobile banking app, in Material 3 Expressive on a dark green-tinted scheme. A pill search bar (56px, 28px radius) floats in the top app bar region above a balance card and recent transactions. Tapping the bar makes the same element grow to fill the screen — its inset goes 16px→0, top 62px→0, height 56px→100%, radius 28px→0 — while the search icon rotates into a back arrow, the avatar shrinks away and the suggestions body fades up 200ms later. Typing filters transactions live with highlighted matches. The back arrow or Esc runs the transform in reverse on a faster accelerate curve. The detail worth copying is that nothing is duplicated: one container, one input, one leading icon button that morphs its glyph.

Reference behaviour

  1. Initial state: search pill at top:62px; left:16px; right:16px; height:56px, background --surface-high, with a search glyph, the placeholder “Search transactions” (17px) and a 32px avatar “LN”. Behind it: a primary-container balance card (“24 318,40” at 40px) and a list of six transactions.
  2. Tap anywhere on the pill: body.open is set. Over 500ms cubic-bezier(.2,0,0,1) the container moves to inset:0, height 100%, radius 0, background --surface-c. The input row gains 54px top margin (status clearance) on the same clock.
  3. The leading button’s search glyph fades out and rotates +90°, the back arrow fades in from −90° (150ms opacity, 500ms rotate). The avatar scales to .6 and fades in 150ms. The input receives focus at ~120ms.
  4. The body (recent-search chips + “Suggestions” list of 4 transactions) fades from 0 and rises 8px, starting 200ms after the tap: opacity 300ms, transform 400ms, emphasized.
  5. Type: the list filters transactions whose name contains the query (case-insensitive); the matched substring is rendered in --primary at weight 600. The heading becomes “2 results” / “1 result”. A clear (×) button appears at the right of the input. No match: list empties and “No transactions match “xyz”.” shows.
  6. Tap a recent-search chip: its text fills the input and filters immediately.
  7. Tap the back arrow, press Esc: container returns to the pill geometry over 300ms cubic-bezier(.3,0,.8,.15); body fades out in 150ms with no delay; glyphs and avatar reverse; the query is cleared; focus returns to the pill.
  8. While open, the page behind is aria-hidden="true" and its list is fully covered.

Structure

390 × 844 — closed                          open
┌────────────────────────────────────┐   ┌────────────────────────────────────┐
│ (54px clearance)                   │   │ (54px clearance — row margin)      │
│ ╭────────────────────────────────╮ │   │ ←  Search transactions|         ×  │  row 56px
│ │ ⌕ Search transactions      (LN)│ │   │ RECENT SEARCHES                    │
│ ╰────────────────────────────────╯ │   │ [◷ Halden Ceramics][◷ rent][◷ …]  │  chips 36px
│ ╭────────────────────────────────╮ │   │ SUGGESTIONS                        │
│ │ Everyday · SEK                 │ │   │ [HC] Halden Ceramics    -1 240,00  │  56px rows
│ │ 24 318,40                      │ │   │ [LS] Salary · Loam    +38 900,00   │
│ │ Available · payday in 4 days   │ │   │ [NP] Nord Post             -84,00  │
│ ╰────────────────────────────────╯ │   │ [MC] Marrow Coffee         -46,00  │
│ RECENT                             │   │                                    │
│ [HC] Halden Ceramics    -1 240,00  │   │                                    │
│ [LS] Salary · Loam    +38 900,00   │   │                                    │
│ ...                                │   │                                    │
└────────────────────────────────────┘   └────────────────────────────────────┘
  pill: top 62 · inset 16 · h 56 · r 28      view: inset 0 · h 100% · r 0
  • <main id="main"> — scrolls; padding-top:134px (62 + 56 + 16) so content starts under the pill. Contains .bal card, <h2>, <ul class="tx">.
  • <div class="sv" role="search"> — the morphing container, position:absolute; z-index:2; overflow:hidden.
    • .row — 56px flex row: <button class="ib" id="lead"> (two stacked SVGs: .lens, .back), <input type="search">, .av avatar, <button class="ib clr">, and <button class="hit"> (an invisible full-row button used only when closed).
    • .body — <h3> + .chips, <h3 id="rh"> + <ul class="res" aria-live="polite">, <p class="empty">.

Sample data (six transactions; [name, meta, amount, tile initials]; positive amounts render in --primary):

NameMetaAmountTile
Halden CeramicsCard · 27 Sep−1 240,00HC
Salary · Loam StudioTransfer · 25 Sep+38 900,00LS
Nord PostPostage · 24 Sep−84,00NP
Marrow CoffeeCard · 24 Sep−46,00MC
Rent · Brf TesselStanding order · 1 Sep−11 250,00BT
Mira PharmacyCard · 22 Sep−312,50MP

Recent-search chips: “Halden Ceramics”, “rent”, “coffee”. Balance card: “Everyday · SEK” / “24 318,40” / “Available · payday in 4 days”. Closed suggestions show the first four rows.

Tokens

:root {
  /* tonal surfaces — dark, green bias */
  --surface: #0f1512;
  --surface-low: #161d19;       /* row hover behind */
  --surface-c: #1b2320;         /* open search view */
  --surface-high: #252e2a;      /* closed pill, icon tiles, result hover */
  --surface-highest: #303935;
  --on-surface: #dfe4df;
  --on-surface-v: #b6c4b9;      /* placeholder, secondary text */
  --outline: #7f8d82;
  --outline-v: #3b4641;         /* chip border */

  /* primary — sage */
  --primary: #9ad6a8;           /* avatar, match highlight, positive amounts, focus */
  --on-primary: #003919;
  --primary-c: #1f4d2f;         /* balance card */
  --on-primary-c: #b5f2c3;

  /* type */
  --font: "Gabarito", system-ui, sans-serif;
  --fs-balance: 40px; --fs-input: 17px; --fs-body: 15px; --fs-chip: 14px; --fs-h: 13px; --fs-tile: 12px;

  /* geometry of the transform */
  --bar-h: 56px;
  --r-bar: 28px;
  --inset: 16px;
  --top: 62px;                  /* 54 clearance + 8 */
  --r-card: 28px;
  --r-row: 16px;
  --r-tile: 12px;
  --r-chip: 10px;

  /* motion */
  --t-micro: 150ms;
  --t-big: 500ms;               /* open */
  --t-exit: 300ms;              /* close */
  --t-body-delay: 200ms;
  --ease-emph: cubic-bezier(.2, 0, 0, 1);
  --ease-exit: cubic-bezier(.3, 0, .8, .15);
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
BalanceGabarito40px6001.05−0.03emnumerals
Balance labelsGabarito13px5001.40sentence
Input / placeholderGabarito17px40010sentence
Section headingGabarito13–14px6001.3+0.06emUPPERCASE
Row titleGabarito15px5001.350sentence
Row subtitleGabarito13px4001.40sentence
AmountGabarito15px60010tabular numerals
ChipGabarito14px50010as typed
Icon tile initialsGabarito12px70010UPPERCASE
Match highlightGabarito15px600—0colour --primary

Motion

ElementTriggerPropertyFrom → ToDurationEasingDelay
.svopentop, left, right, height, border-radius, background62/16/16/56px/28px/high → 0/0/0/100%/0/surface-c500ms--ease-emph0
.svclosesamereverse300ms--ease-exit0
.rowopenmargin-top0 → 54px500ms--ease-emph0
.lensopenopacity, rotate1, 0 → 0, 90°150 / 500ms--ease-emph0
.backopenopacity, rotate0, −90° → 1, 0150 / 500ms--ease-emph0
.avopenopacity, scale1, 1 → 0, .6150mslinear0
.bodyopenopacity, translateY0, 8px → 1, 0300 / 400ms--ease-emph200ms
.bodycloseopacity, translateY1, 0 → 0, 8px150ms--ease-exit0
.clrtypingopacity0 → 1150mslinear0
.res lihoverbackgroundtransparent → surface-high0—instant

Reduced motion: all durations 1ms, delays 0. The view still swaps between pill and full screen; focus still moves into the input.

States

  • Closed: container is the pill; .hit covers the row and is the only tab stop; input and leading button are tabindex="-1"; leading button labelled “Search”.
  • Open: body.open; .hit is display:none; input tabindex="0" and focused; leading button labelled “Back”; main is aria-hidden="true".
  • Has query: .row.has — clear button visible; heading shows result count; matches highlighted.
  • Empty result: .res empty, .empty visible with the quoted query.
  • Focus-visible: 3px --primary outline, −3px offset on buttons, chips, the hit area and result rows.
  • Positive amount: .in colour --primary.

Accessibility

  • Container is role="search"; the input has aria-label="Search transactions" and type="search".
  • Closed, the entire pill is one <button aria-label="Open search"> in the tab order; open, the order is Back → input → Clear (when visible) → chips → results (rows are tabindex="0").
  • Esc closes and returns focus to the pill’s hit button.
  • Results list is aria-live="polite" so counts and matches are announced as you type; the heading text (“3 results”) changes with it.
  • The page behind gets aria-hidden="true" while open.
  • Contrast: --on-surface-v on --surface-c 9.4:1; --primary on --surface-c 9.6:1; --on-primary-c on --primary-c 8.5:1.
  • Hit targets: leading and clear buttons 48px, pill 56px, chips 36px (allow 44px row spacing), result rows 56px.

Responsive rules

  • 390 wide: as specified.
  • 360 wide: identical geometry; the balance figure drops to 36px so it fits with the labels.
  • ≥ 600 wide: keep the pill at max-width:560px centred (left/right:auto; margin:0 auto), and open to a 560px centred sheet with 28px radius rather than full-bleed; the scrim behind is rgba(15,21,18,.5).
  • Landscape: the open row still reserves the top safe area; the body scrolls internally.

Acceptance checklist

  • Closed pill is 56px tall, 28px radius, inset 16px, top 62px, #252E2A.
  • Opening transitions top/left/right/height/border-radius on one element over 500ms cubic-bezier(.2,0,0,1) to inset:0; radius:0.
  • Closing uses 300ms cubic-bezier(.3,0,.8,.15).
  • The search glyph rotates +90° out while the back arrow rotates in from −90°; both share the 500ms clock, opacity 150ms.
  • The suggestions body starts fading in 200ms after the tap and rises 8px.
  • Input is focused within 150ms of opening; Esc and the back arrow close.
  • Typing filters by substring, highlights the match in #9AD6A8, and updates the heading to “N result(s)”.
  • A clear button appears only when the input has text; it empties the input and keeps focus there.
  • Empty results show “No transactions match ”…”.” and the list is empty.
  • The page behind is aria-hidden while the view is open.
  • Focus rings are visible on the pill, back, clear, chips and result rows.
  • Reduced motion: state change still completes; no residual delays.

Implementation notes

Morph one absolutely positioned container by its box, not by scale. Scaling would distort the text; transitioning the inset keeps everything crisp. Give open and closed rules different transition lists so the two speeds are explicit:

.sv { position:absolute; top:62px; left:16px; right:16px; height:56px; border-radius:28px; overflow:hidden;
  transition: top 300ms var(--ease-exit), left 300ms var(--ease-exit), right 300ms var(--ease-exit),
              height 300ms var(--ease-exit), border-radius 300ms var(--ease-exit), background 300ms; }
.open .sv { top:0; left:0; right:0; height:100%; border-radius:0; background:var(--surface-c);
  transition: top 500ms var(--ease-emph), left 500ms var(--ease-emph), right 500ms var(--ease-emph),
              height 500ms var(--ease-emph), border-radius 500ms var(--ease-emph), background 500ms; }

Glyph swap by rotation — stack both SVGs absolutely in one 48px button:

.ib svg { position:absolute; transition: opacity 150ms, transform 500ms var(--ease-emph); }
.ib .back { opacity:0; transform:rotate(-90deg); }
.open .ib .back { opacity:1; transform:none; }
.open .ib .lens { opacity:0; transform:rotate(90deg); }

Highlight the match without innerHTML injection — split the name around the index:

const i = name.toLowerCase().indexOf(q.toLowerCase());
b.append(name.slice(0, i));
const m = document.createElement('mark'); m.textContent = name.slice(i, i + q.length);
b.append(m, name.slice(i + q.length));

Common mistakes: fading the pill out and a new full-screen view in (breaks continuity — it must be the same element); forgetting the 54px top margin on the row when open, so the input lands under the status bar; leaving the input focusable while closed (two tab stops for one control).

Details

Palette
Type
Gabarito
Motion
Rich motion
Build
One session
Designed bySusan AcharyaKathmandu

Studied

Don't Board Me

A control. It studies this site for quiet, not for a show.

All sources