Connected button group

Hover, Tab to or press a button. Selected: week.

How it works

Squircles, and corners that change shape

For thirty years a CSS corner could only be a quarter ellipse. corner-shape adds a second knob: border-radius still says how big the corner is, corner-shape says what curve fills it, from a straight bevel through round and squircle to a sharp square, and it animates.

1. corner-shape

corner-shape only does something where border-radius is set: the radius reserves a box in each corner, the shape decides the curve that runs through that box. Like border-radius it has per-corner longhands such as corner-top-left-shape, and up to four values in the shorthand. Every keyword is shorthand for one superellipse():

square
superellipse(infinity): no rounding at all, as if the radius were 0.
squircle
superellipse(2): the shape of app icons. Flatter sides, a smoother start of the curve, no visible point where the straight edge turns into the arc.
round
superellipse(1): the classic quarter ellipse, the default.
bevel
superellipse(0): a straight cut between the two ends of the corner.
scoop
superellipse(-1): a concave quarter circle, as if bitten out.
notch
superellipse(-infinity): a square cut-out.

Borders, outlines, box-shadow and the background all follow the shape, so the focus ring of a squircle button is a squircle too (checked in Chromium 155). That is the big advantage over every older technique.

.tile {
  border-radius: 64px; /* the size of each corner */
  corner-shape: squircle; /* the curve inside it */
  transition-property: corner-shape, border-radius;
  transition-duration: 600ms;
  transition-timing-function: linear(0, 0.032, 0.115, 0.229, 0.358, 0.492, 0.619, 0.735, 0.835, 0.918, 0.983, 1.031, 1.064, 1.084, 1.093, 1.094, 1.089, 1.08, 1.069, 1.056, 1.044, 1.032, 1.021, 1.013, 1.005, 1, 0.996, 0.993, 0.992, 0.991, 0.991, 0.992, 0.993, 0.994, 0.995, 0.996, 0.997, 0.998, 0.999, 1, 1);
}

@media (prefers-reduced-motion: reduce) {
  .tile { transition-duration: 0s; }
}

2. The superellipse

A superellipse is the set of points where |x/a|ⁿ + |y/b|ⁿ = 1. With n = 2 that is an ellipse. Larger n pushes the curve out towards the corners of its bounding box (at n = 4 you get the squircle, at ∞ a rectangle), and n = 1 is a diamond of straight lines. CSS writes the exponent as superellipse(k) with n = 2k, so that the useful shapes get small, evenly spaced numbers: 0 is bevel, 1 round, 2 squircle.

For a corner, each quarter of the curve is drawn into the box that border-radius reserves, centered on the box's inner corner. Negative k doesn't use n < 1; instead the curve for |k| is mirrored across the line between the corner's two end points, which turns round into scoop and square into notch. The fallback below generates this curve, with x = r − r·cos(t)2/n and y = r − r·sin(t)2/n for t from 0 to 90°.

3. Animating between shapes

corner-shape is animatable, so a plain transition morphs any shape into any other, including convex into concave. Chromium doesn't interpolate k linearly, though. It interpolates the point where the curve crosses the corner's diagonal (0.51/2k of the way from the curve's center, mirrored for negative values), which looks even: halfway from round (1) to squircle (2) is superellipse(1.436), and halfway from bevel to square is 1.269. The Computed line at the top of the control panel shows the browser's value on every frame; the project's interpolateK() reproduces it and is tested against values read from Chromium 155.

border-radius and corner-shape transition independently, so you can change both at once (the button group does) and they stay in sync as long as they share duration and easing.

4. Spring easing with linear()

Material 3 Expressive moves shapes with springs: fast out of the gate, a small overshoot, then a settle. cubic-bezier() can overshoot once at most and can't wobble. linear() can: it takes a list of output values and draws straight lines between them, evenly spaced in time unless you give positions. With enough points, any curve fits.

The spring easing samples a damped harmonic oscillator, x(t) = 1 − e−ζωt(cos(ωdt) + ζω/ωd·sin(ωdt)), at 41 points. Bounce lowers the damping ratio ζ = 1 − bounce; at 0 it is critically damped and never overshoots. The stiffness ω is solved for each ζ so that the spring is within 0.2% of its target at the end of the duration (for the critically damped spring that takes ω ≈ 8.46), so the final value 1 doesn't jump. Your current easing:

transition-timing-function: linear(0, 0.032, 0.115, 0.229, 0.358, 0.492, 0.619, 0.735, 0.835, 0.918, 0.983, 1.031, 1.064, 1.084, 1.093, 1.094, 1.089, 1.08, 1.069, 1.056, 1.044, 1.032, 1.021, 1.013, 1.005, 1, 0.996, 0.993, 0.992, 0.991, 0.991, 0.992, 0.993, 0.994, 0.995, 0.996, 0.997, 0.998, 0.999, 1, 1);

An overshoot is applied to the property value: k briefly goes past its target, a radius dips below its end value, flex-grow overshoots. Values that can't go negative are clamped by the browser, so a spring towards a radius of 0 bounces off the floor rather than below it.

5. The connected button group

A flex row of toggle buttons, each flex: 1 1 0 so they share the width equally. Hovering, focusing or pressing one raises its flex-grow, and because flex-grow is a number it transitions: the active button pushes its neighbors aside while it morphs from pill to squircle corners. Selection is aria-pressed, so screen readers announce "toggle button, pressed", and a check mark shows it without relying on color.

  • The pill radius is exactly half the height, not the usual 9999px. A transition from 9999px to 14px would spend almost all its time above the visible maximum, so the morph would seem to start late and then snap.
  • Hover expansion sits inside @media (hover: hover). Touch screens fire a sticky hover after a tap, which would leave a button expanded. Touch users still get the press squish (:active) and the selection morph.
  • :focus-visible gets the same treatment as hover, so keyboard users see which button they are on, in addition to the focus ring (which follows the corner shape).
.group { display: flex; gap: 4px; }

.group button {
  flex: 1 1 0;
  min-width: max-content;
  height: 48px;
  border-radius: 24px; /* exactly half the height: a pill */
  corner-shape: round;
  transition-property: flex-grow, border-radius, corner-shape, background-color;
  transition-duration: 600ms;
  transition-timing-function: linear(0, 0.032, 0.115, 0.229, 0.358, 0.492, 0.619, 0.735, 0.835, 0.918, 0.983, 1.031, 1.064, 1.084, 1.093, 1.094, 1.089, 1.08, 1.069, 1.056, 1.044, 1.032, 1.021, 1.013, 1.005, 1, 0.996, 0.993, 0.992, 0.991, 0.991, 0.992, 0.993, 0.994, 0.995, 0.996, 0.997, 0.998, 0.999, 1, 1);
}

.group button[aria-pressed='true'] {
  border-radius: 14px;
  corner-shape: squircle;
}

.group button:focus-visible {
  flex-grow: 1.5;
  border-radius: 14px;
  corner-shape: squircle;
}

/* only where hovering exists: a tap leaves a sticky hover behind */
@media (hover: hover) {
  .group button:hover {
    flex-grow: 1.5;
    border-radius: 14px;
    corner-shape: squircle;
  }
}

.group button:active {
  flex-grow: 1.75;
  border-radius: 7px;
  corner-shape: squircle;
}

@media (prefers-reduced-motion: reduce) {
  .group button { transition-duration: 0s; }
}

6. The fallback: clip-path from the same formula

Where corner-shape isn't supported, Squircle measures itself with a ResizeObserver and clips to a path() built from the formula above: four corners of 24 line segments each, joined by the straight edges. Paths can only interpolate when they have the same commands in the same order, so the generator always emits the same number of points whatever the shape. That keeps the morph working in the fallback too. Set Renderer to clip-path to compare it.

/* 240 × 160 px, radius 64px, k = 2, 24 segments per corner, as in the component */
@supports not (corner-shape: squircle) {
  .tile {
    border-radius: 0;
    clip-path: path("M0 64L0.07 47.63L0.27 40.88L0.62 35.73L1.1 31.44L1.72 27.71L2.48 24.41L3.39 21.44L4.44 18.75L5.64 16.3L6.99 14.07L8.51 12.03L10.18 10.18L12.03 8.51L14.07 6.99L16.3 5.64L18.75 4.44L21.44 3.39L24.41 2.48L27.71 1.72L31.44 1.1L35.73 0.62L40.88 0.27L47.63 0.07L64 0L176 0L192.37 0.07L199.12 0.27L204.27 0.62L208.56 1.1L212.29 1.72L215.59 2.48L218.56 3.39L221.25 4.44L223.7 5.64L225.93 6.99L227.97 8.51L229.82 10.18L231.49 12.03L233.01 14.07L234.36 16.3L235.56 18.75L236.61 21.44L237.52 24.41L238.28 27.71L238.9 31.44L239.38 35.73L239.73 40.88L239.93 47.63L240 64L240 96L239.93 112.37L239.73 119.12L239.38 124.27L238.9 128.56L238.28 132.29L237.52 135.59L236.61 138.56L235.56 141.25L234.36 143.7L233.01 145.93L231.49 147.97L229.82 149.82L227.97 151.49L225.93 153.01L223.7 154.36L221.25 155.56L218.56 156.61L215.59 157.52L212.29 158.28L208.56 158.9L204.27 159.38L199.12 159.73L192.37 159.93L176 160L64 160L47.63 159.93L40.88 159.73L35.73 159.38L31.44 158.9L27.71 158.28L24.41 157.52L21.44 156.61L18.75 155.56L16.3 154.36L14.07 153.01L12.03 151.49L10.18 149.82L8.51 147.97L6.99 145.93L5.64 143.7L4.44 141.25L3.39 138.56L2.48 135.59L1.72 132.29L1.1 128.56L0.62 124.27L0.27 119.12L0.07 112.37L0 96Z");
  }
}

clip-path clips everything outside the path: the element's own outline, focus ring and box-shadow included. A focusable element clipped this way needs its focus ring on an unclipped wrapper, or a filter: drop-shadow() on a parent, which follows the clipped shape. That is why the button group doesn't use the fallback: its buttons keep plain border-radius and a normal focus ring, and only the radius morphs. CSS shape() could express the path in percentages without measuring, but it is newer than path(), so path() is the safer fallback.

Accessibility

  • All morphs are transitions triggered by the user. Under prefers-reduced-motion: reduce they are instant: the shape still changes, it just doesn't animate. Nothing loops.
  • The buttons are native <button>s in a labelled role="group": Tab moves between them, Space and Enter toggle them.
  • In forced-colors mode (Windows High Contrast) backgrounds disappear, so the buttons get a system-colored border and the selected one is drawn in Highlight.

Browser support

corner-shape and superellipse() ship in Chromium 139 and later. Tested in Chromium 155: every keyword and superellipse() value parses, renders and interpolates, and borders, outlines and shadows follow the shape. Firefox and Safari were not tested. That doesn't matter for the component: it feature-detects with CSS.supports('corner-shape', 'squircle'), which (unlike some other checks in this playground) is only true where the property is actually implemented, because an engine that doesn't know a property doesn't parse it. The check runs after the page has loaded, and any engine where it is false gets the clip-path fallback. clip-path: path(), linear() easing and transitions of flex-grow work in all current browsers.

Performance notes

  • A corner-shape or border-radius transition repaints the element on every frame; it isn't a compositor animation. For buttons and cards that is cheap.
  • Animating flex-grow re-runs layout for the row on every frame. Fine for a handful of buttons; don't use it on long lists or large layouts.
  • The clip-path fallback recomputes the path whenever the element is resized. Size changes (and the initial measurement after load) skip the transition for a frame, so the clip follows the box immediately instead of trailing it; only shape and radius changes morph.

Back to the demo ↑