GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Expanding Button UI: 9 Shape-Morph Patterns

Expanding button UI: One of nine grows corner rounding as its open value changes, one spins a plus into a close mark as a closed value turns on, and one lets a

Auto-plays · click a tile to jump to its section · all nine in one zip

None of these nine demos talk to a server, touch localStorage, or roll a random number — item 06's hint list pulls from a fixed three-string array rather than a real search API, and every other pattern is one boolean or numeric state value plus a handful of constants deciding how that value's change looks on screen. That narrow scope is what makes a shape-morph collection like this readable as a checklist: name the value that flips, and the pattern names itself without needing the code open first.

Anatomy of nine patterns — which shape value changes and when

Only two of the nine run a requestAnimationFrame loop: item 04's width toggle integrates a = -K*(cur-target) - D*vel every frame so its expanded width overshoots REST_W before settling, and item 09's collapse toggle runs the identical spring formula on a scaleX value instead of a pixel width. Item 02 fakes a similar overshoot with a single CSS transition riding the $ease-elastic curve, so its SPIN_DEG and OVER_MS constants describe an intended angle and window rather than anything computed per frame. Five of the nine measure real layout before painting: item 01 sums each list item's getBoundingClientRect().height into --panel-h, items 04 and 08 both flip a label to width: auto momentarily to read its natural size, item 05 reads the shell's width for --shell-w, and item 06 reads the input's width for --input-w once it opens. That measured-versus-fixed split is a real architectural line: items 02, 03, 07, and 09 work off constants alone (RADII, STEP_PX, K, D) with no DOM measurement call, so their shape math never adapts to font size or content length the way the measured five do. Every listener answers a real interaction — items 01, 02, 04, and 07 listen for click; item 03 pairs click with keydown for Enter and Space on its custom role="button" div; item 05 delegates click across its shell and two confirm buttons; item 06 listens for click on its close button plus input and focus on its field; item 08 listens for pointerenter, pointerleave, click, and focus; and item 09 listens for pointerdown, pointerup, and click, since its snap-back begins its squeeze on pointerdown and completes the toggle on pointerup, with click kept alongside so a keyboard-triggered press still flips the state.

Code per pattern

01Accordion-reveal value toggle — when the button's open value changes, its rounding and shadow values change along, presenting the content below

As the open value changes, the button's corner rounding grows and its shadow deepens, while the content appears beneath it. This value toggle suits an FAQ item toggle or a settings group expand, where the reveal itself carries some visual weight.

Accordion-reveal value toggle — when the button's open value changes, its rounding and shadow values change along, presenting the content below
Open in a new tab

01 · Accordion-reveal value toggle — when the button's open value changes, its rounding and shadow values change along, presenting the content below — Measured area 18.301% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

STAGGER_MSmeasuretoggle
  var STAGGER_MS = 70; // Calculation layer: item index * this value delays each reveal

  // Calculation layer: measure how much height the list items actually occupy with
  // getBoundingClientRect and pass it to panel's --panel-h — a real value for the current font and content, not a guessed constant.
  function measure() {
    var h = 0;
    items.forEach(function (li) { h += li.getBoundingClientRect().height; });
    panel.style.setProperty('--panel-h', h + 'px');
  }
  items.forEach(function (li, i) {
    li.style.transitionDelay = (i * STAGGER_MS) + 'ms';
  });

What the numbers say

Item 01 fills 18.3% of the frame cumulatively at 154.3 average intensity while moving in 8 of 23 sampled frames, the stagger loop spacing each list item's reveal instead of firing all three at once.

02Plus-spin value toggle — when a closed value turns on, a plus shape rotates into a close mark

When the closed value turns on, the plus shape rotates into a close mark, passing slightly beyond the target angle before returning. It reads naturally as an add-item button or a filter-open toggle, where the icon itself communicates the action.

Plus-spin value toggle — when a closed value turns on, a plus shape rotates into a close mark
Open in a new tab

02 · Plus-spin value toggle — when a closed value turns on, a plus shape rotates into a close mark — Measured area 0.642% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

SPIN_DEGOVER_MStoggle
.pc__icon {
  display: block;
  transition: transform 360ms $ease-elastic;
}

.pc__v, .pc__h {
  stroke: $stage-yellow;
  stroke-width: 2.4;
  stroke-linecap: round;
  fill: none;
}

// Drawing the stroke: the vertical bar uses pathLength 1, so dashoffset runs 0 → 1 to disappear
.pc__v {
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 270ms $ease-out;
}

.pc--closed {
  .pc__icon { transform: rotate(135deg); }
  .pc__v { stroke-dashoffset: 1; }
  .pc__disc { background: rgba($stage-yellow, .32); }
}

What the numbers say

Item 02 fills a thin 0.64% of the frame at 56.7 average intensity across 8 of 23 frames, since an icon rotating in place moves far less pixel area than a panel opening beneath a button.

03Corner-rounding value growth — the more the open value grows, the more the button's corner rounding grows with it

Each time the open value grows a step, the corner rounding grows by the same amount, so the button takes a progressively softer form. A next button in a multi-step form or a step-select card can use that shape-morph progression to signal advancing state without adding new text.

Corner-rounding value growth — the more the open value grows, the more the button's corner rounding grows with it
Open in a new tab

03 · Corner-rounding value growth — the more the open value grows, the more the button's corner rounding grows with it — Measured area 1.665% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

RADIIRADII_TEXTadvance
  var RADII = [12, 16, 24, 999]; // Calculation layer: rounding value (px) grows each step — 999 is the pill
  var RADII_TEXT = ['Rounding value 12px', 'Rounding value 16px', 'Rounding value 24px', 'Rounding value pill'];
  var step = 0;

  function paint() {
    var r = RADII[step];
    // Interpolate rounding in px, not %, so the growth reads as linear — 999px stands for the pill
    btn.style.borderRadius = (r === 999 ? '999px' : r + 'px');
    read.textContent = RADII_TEXT[step];
    fill.style.transform = 'scaleX(' + ((step + 1) / RADII.length) + ')';
    dots.forEach(function (d, i) {
      d.classList.toggle('cr__dot--on', i <= step);
    });
  }

  function advance() {
    step = (step + 1) % RADII.length;
    paint();
  }

What the numbers say

Item 03 fills 1.67% of the frame at 68.6 average intensity while moving in 15 of 23 frames, the highest frame count in the set because its four-step autoplay repaints the border-radius, fill bar, and dot row on every advance.

04Width-inertia value toggle — while an expand value grows, a width value overshoots its target and returns

As the expand value grows, the width value passes slightly beyond its target and comes back, making the growth look weighted. An inline read-more button or an in-place toolbar open benefits from that weighted settle, since it reads as more deliberate than an instant snap.

Width-inertia value toggle — while an expand value grows, a width value overshoots its target and returns
Open in a new tab

04 · Width-inertia value toggle — while an expand value grows, a width value overshoots its target and returns — Measured area 4.333% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

REST_WKD
  function measure() {
    var prev = more.style.width;
    more.style.width = 'auto';
    var w = more.getBoundingClientRect().width;
    more.style.width = prev || '0px';
    return Math.ceil(w);
  }
  var openW = measure() + REST_W + 24; // 24 = extra margin added to the base width

  var open = false;
  var cur = REST_W, vel = 0, target = REST_W;
  var raf = null;

  // Spring-damping calculation: every frame a = -K*(cur-target) - D*vel — overshoots, then settles
  function tick() {
    var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
    vel += a;
    cur += vel;
    if (Math.abs(cur - target) < 0.4 && Math.abs(vel) < 0.05) { cur = target; vel = 0; }
    btn.style.width = cur.toFixed(1) + 'px';
    var p = Math.max(0, Math.min(1, (cur - REST_W) / (openW - REST_W)));
    more.style.opacity = String(p);
    raf = requestAnimationFrame(tick);
  }

What the numbers say

Item 04 fills 4.33% of the frame at 164.3 average intensity — the highest intensity in the set — across 14 of 23 frames, the spring's overshoot-then-settle keeping the button's edge in motion longer than a single-step transition would.

05Split-dual value reveal — when a confirm value turns on, the button splits into two pieces, each revealing a different value

When the confirm value turns on, the button splits into two pieces that part in opposite directions, revealing the values inside. A delete-confirm button or a dual-choice confirmation fits this pattern well, since the split itself signals that a choice is now required.

Split-dual value reveal — when a confirm value turns on, the button splits into two pieces, each revealing a different value
Open in a new tab

05 · Split-dual value reveal — when a confirm value turns on, the button splits into two pieces, each revealing a different value — Measured area 6.995% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

PART_PXmeasuretoggle
  var PART_PX = 26; // Calculation layer: distance (px) the two halves part — sized off half the shell width

  // Calculation layer: measure the shell's width with getBoundingClientRect and pass it as --shell-w.
  // Each half is drawn at 50% of the shell width, so the parting distance is scaled to this same value.
  function measure() {
    shell.style.setProperty('--shell-w', shell.getBoundingClientRect().width + 'px');
  }

  var split = false;
  function paint() {
    root.classList.toggle('sd--split', split);
    shell.setAttribute('aria-expanded', String(split));
  }

  function toggle() {
    split = !split;
    paint();
  }

What the numbers say

Item 05 fills 7.0% of the frame at exactly 100.0 average intensity while moving in 7 of 23 frames, two halves parting to opposite edges in one transition step.

06Pill-to-bar value toggle — when a search value turns on, a pill shape value changes into a bar shape value, creating an input space

When the search value turns on, the pill shape stretches into a bar shape with an input space appearing; turning it off shrinks it back to a pill. A header search button or a search open on narrow screens suits this pattern, and its hint list stays fixed rather than querying anything live.

Pill-to-bar value toggle — when a search value turns on, a pill shape value changes into a bar shape value, creating an input space
Open in a new tab

06 · Pill-to-bar value toggle — when a search value turns on, a pill shape value changes into a bar shape value, creating an input space — Measured area 8.393% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · See the password

BAR_WSEEDSpaintHints
  // Seed-fixed local values — no real search API is used
  var SEEDS = ['Sample value 1', 'Sample value 2', 'Sample value 3'];
  function paintHints() {
    var hints = root.querySelectorAll('.ps__hint');
    var q = input.value.trim();
    var n = q ? Math.min(2, SEEDS.length) : 0;
    hints.forEach(function (h, i) {
      h.classList.toggle('ps__hint--on', i < n);
      if (i < n) h.textContent = SEEDS[i];
    });
  }

What the numbers say

Item 06 fills 8.39% of the frame at 141.9 average intensity across 7 of 23 frames, the pill-to-bar width change and the appearing input carrying most of that area.

07Stack-lift value toggle — when a detail value turns on, identical shape values stacked behind lift up in turn

When the detail value turns on, identical shapes stacked behind the button lift with a one-beat stagger; turning it off lowers them in turn. A card stack more-button or a notification list open can use that staggered lift to hint at how many items wait behind the front card.

Stack-lift value toggle — when a detail value turns on, identical shape values stacked behind lift up in turn
Open in a new tab

07 · Stack-lift value toggle — when a detail value turns on, identical shape values stacked behind lift up in turn — Measured area 12.562% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

STEP_PXSTAGGER_MStoggle
  var STEP_PX = 34; // Calculation layer: the y-offset (px) one stacked card lifts by — worked out via spring damping
  var STAGGER_MS = 80; // Calculation layer: cards further back lift this much later

  ghosts.forEach(function (g, i) {
    g.style.transitionDelay = ((ghosts.length - 1 - i) * STAGGER_MS) + 'ms';
  });

  var open = false;
  function paint() {
    root.classList.toggle('sl--open', open);
    btn.setAttribute('aria-expanded', String(open));
    sub.textContent = open ? '3 values expanded' : '3 values stacked behind';
  }

  function toggle() {
    open = !open;
    paint();
  }

What the numbers say

Item 07 fills 12.56% of the frame at 59.5 average intensity while moving in 8 of 23 frames, three staggered ghost cards lifting in sequence behind the front card.

08Icon-grow label value toggle — when a focus value turns on, an icon's size value grows and a label's width value stretches

When the focus value turns on, the icon's size value grows and the label's width value stretches after it; turning it off shrinks both. A round tool button or an action bar with appearing labels fits this pattern, since the label only needs space once attention lands on it.

Icon-grow label value toggle — when a focus value turns on, an icon's size value grows and a label's width value stretches
Open in a new tab

08 · Icon-grow label value toggle — when a focus value turns on, an icon's size value grows and a label's width value stretches — Measured area 1.013% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

ICON_ONICON_OFFmeasure
  var ICON_ON = 1.25, ICON_OFF = 1; // Calculation layer: display scale values — multiplied depending on the focus value

  // Calculation layer: measure the label's width with getBoundingClientRect and pass it as --label-w.
  // The max-width transition must be paired with the measured width, or the text gets clipped mid-word.
  function measure() {
    var prev = label.style.maxWidth;
    label.style.maxWidth = 'none';
    var w = Math.ceil(label.getBoundingClientRect().width);
    label.style.maxWidth = prev || '0px';
    tool.style.setProperty('--label-w', w + 'px');
  }

  var on = false;
  function paint() {
    measure();
    root.classList.toggle('ig--on', on);
    tool.setAttribute('aria-pressed', String(on));
  }

What the numbers say

Item 08 fills a modest 1.01% of the frame at 68.6 average intensity across 8 of 23 frames, since only the icon's scale and the label's width change while the toolbar itself stays fixed — a real DOM measurement behind a small visible move is what separates a considered expand collapse button ui from one that guesses a fixed pixel width and hopes it matches the rendered text.

09Elastic snap-back value toggle — when a collapsed value turns on, a shape value shrinks, stretches slightly the other way, then returns to form

When the collapsed value turns on, the shape value shrinks, stretches slightly the opposite way, then returns to form, so the collapse looks elastic. A filter-collapse button or a comment box close suits this pattern, since the snap-back reads as a direct response to the press-release gesture.

Elastic snap-back value toggle — when a collapsed value turns on, a shape value shrinks, stretches slightly the other way, then returns to form
Open in a new tab

09 · Elastic snap-back value toggle — when a collapsed value turns on, a shape value shrinks, stretches slightly the other way, then returns to form — Measured area 11.242% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

KDtick
  var K = 190, D = 14; // Calculation layer: spring constant and damping — set the snap-back value at the instant of press-release

  var collapsed = false;
  var cur = 1, vel = 0, target = 1;
  var raf = null;

  // Spring-damping calculation: every frame a = -K*(cur-target) - D*vel — shrinks, stretches slightly the other way, then settles into form
  function tick() {
    var a = -K * (cur - target) * 0.001 - D * vel * 0.01;
    vel += a;
    cur += vel;
    if (Math.abs(cur - target) < 0.004 && Math.abs(vel) < 0.005) { cur = target; vel = 0; }
    btn.style.transform = 'scaleX(' + cur.toFixed(3) + ')';
    var p = Math.max(0, Math.min(1, (1 - cur) / 0.28));
    root.style.setProperty('--press', p.toFixed(3));
    raf = requestAnimationFrame(tick);
  }

  function toggle() {
    collapsed = !collapsed;
    target = collapsed ? 0.72 : 1;
    btn.setAttribute('aria-pressed', String(collapsed));
    root.classList.toggle('eb--collapsed', collapsed);
  }

What the numbers say

Item 09 fills 11.24% of the frame at 79.3 average intensity while moving in 12 of 23 frames, the spring settling past its 0.72 target and back on every press-release cycle.

Where values reshape a pattern — corner radius, spin angle, width basis

Accordion-reveal's STAGGER_MS, 70 to 140 doubles the delay between each list item's reveal in item 01, so the panel's contents fill in noticeably one at a time instead of arriving as a single block — the value's propagation becomes something you can actually watch.

Width-inertia's K, 170 to 90 softens item 04's spring constant, so the overshoot past the target width grows larger and takes longer to settle — the same expand collapse button css ends up feeling heavier and more elastic rather than snappy.

Elastic snap-back's D, 14 to 22 raises item 09's damping ratio, so the bounce dies out in fewer cycles and the collapse reads as short and firm instead of loose.

Beyond this one post, 9 CSS Button Animations (SCSS + React, copy-paste) works the hover axis — color and size tweens on a button that never changes its own content or shape the way these nine do. 9 CSS Accordion Animations — No Library, Pure CSS works the content axis: a whole panel of arbitrary height opens and closes, where item 01 here is a button-scoped instance of that same reveal. 9 Icon Button CSS Patterns — Press, Drag, Expand collects icon-only buttons across press, drag, and expand states, and item 02's plus-to-close spin sits closest to that set without ever growing past its own icon. Comparing all three against this post is the fastest way to place a given expand button ui design on the right axis before writing a single line of CSS, and the zip below is what pv87z5gp unlocks.

Where it breaks — the instant text width changes, narrow screens, reduced motion

The instant text width changes

Items 04 and 08 both measure a label's natural width once, at load, by flipping it to width: auto and reading getBoundingClientRect() — if a web font swaps in after that measurement (a FOUT/FOIT reflow), the stored width can be shorter or longer than the text now needs, and the transition will either clip a wider label or leave visible slack around a narrower one until the next remeasure. Item 01 has a related but softer version of the same risk: measure() sums list-item heights once per toggle rather than continuously, so a live font-size change mid-panel-open would not be reflected until the panel closes and reopens.

Narrow screens

All nine carry a @media (max-width: 340px) rule in their SCSS, and none of them switches animation paths there — item 04's shell also gets a max-width: 340px cap on its container so the inline growth never forces a line to wrap, while the others simply compress padding and font size and let the same transitions keep running underneath.

Reduced motion

Every one of the nine checks matchMedia('(prefers-reduced-motion: reduce)') in JavaScript before starting its auto-loop, and every one also ships a CSS @media (prefers-reduced-motion: reduce) block — but what each check settles into differs. Items 01, 02, 04, 05, 06, 07, 08, and 09 jump straight to their open/closed settled state instead of animating there; item 03 alone never starts its setInterval loop and stays on the first painted frame. Item 09 is worth calling out on its own: only the autoplay timer is gated, so a reduced-motion visitor who actually presses the button still drives the same requestAnimationFrame spring — the CSS transitions are what get removed, not the pointer-driven physics.

Three variants and accessibility

Variant What changes What it doesn't touch
Accordion-reveal, wide stagger (STAGGER_MS 70 to 140) Item 01's per-item reveal delay only measure(), the panel markup, the shadow value
Width-inertia, soft spring (K 170 to 90) Item 04's overshoot and settle time only REST_W, D, the measured openW target
Elastic snap-back, fast damping (D 14 to 22) Item 09's bounce decay only K, the 0.72 collapsed target, aria-pressed

Across the nine, announced state stays close to native semantics: items 01, 04, 05 (via its shell), and 07 carry aria-expanded; items 02, 08, and 09 carry aria-pressed; item 03 is the one custom control, built on a role="button" div with tabindex="0" and an aria-label, plus a keydown handler answering Enter and Space the way a native button would for free. Decorative SVG icons across items 01, 02, 05, 07, and 09 are aria-hidden, and item 06's field carries its own aria-label alongside a labeled close button — none of the nine reach for disabled or a tabindex override, so keyboard focus order never depends on which shape state is currently showing.

FAQ

Why do people search for an "expand button ui design" instead of styling one from scratch?

Because the interesting part isn't the CSS alone — it's which single state value a design toggles and what constants govern how far that value travels, and seeing nine solved examples side by side is faster than deriving the spring math or the measured-width pattern from a blank file.

Do any of these nine call a real search or form API?

No — item 06 is the only one shaped like a search field, and its hint list reads from the fixed SEEDS array rather than any network request; every other item's "value" is a local boolean, index, or spring position with no I/O of any kind.

Is item 09's bounce built with a CSS animation or a physics library?

Neither — tick() hand-integrates a = -K*(cur-target) - D*vel every frame using two constants and two accumulating variables, which is the entire spring; nothing in the set imports an animation or physics library to get that overshoot.

Sources: MDN — Using CSS transitions · MDN — Window: requestAnimationFrame() method · MDN — prefers-reduced-motion · MDN — button role

Enter the archive password

The password is inside this article. You will find it as you read.