Most demos need a newer browser

They use :has() and container queries. Update your browser, or on an iPhone update iOS to 16.4 or later.

CSS Squishy Button Group

A CSS flex-grow button group where pressing one button grows it and the neighbors shrink automatically, animated with a spring from linear() easing.

Level
Intermediate
Published
Updated
Reading time
5 min

Try it

2 live demos

Press one and its neighbors make room

Other ways to build it

A segmented control that stays open

See the code

Build it

4 snippets to copy

CSS Squishy Button Group

HTML

<div class="sq-group">
  <button class="sq-item" type="button">Day</button>
  <button class="sq-item" type="button">Week</button>
  <button class="sq-item" type="button">Month</button>
</div>

CSS

.sq-group {
  display: flex;
  gap: 6px;
  max-width: 340px;
}

.sq-item {
  flex: 1 1 0;
  min-width: 0;
  padding: .85rem .4rem;
  border: 1px solid #2a2a2d;
  border-radius: 14px;
  font-weight: 600;
  color: #f0f0f0;
  background: #0c0c0d;
  cursor: pointer;
  /* the first timing is the fallback for browsers without linear() */
  transition: flex-grow .55s cubic-bezier(.34, 1.56, .64, 1), border-radius .3s;
  /* a spring as points: past the target, back, and settled */
  transition-timing-function: linear(0, .907, 1.284, 1.081, .93, .962, 1.014, 1.014, .998, .996, 1);
}

/* flex-grow is the whole trick: the neighbors shrink on their own */
.sq-item:active {
  flex-grow: 1.7;
  border-radius: 22px;
  color: #16210a;
  background: #b8ff57;
}

@media (prefers-reduced-motion: reduce) {
  .sq-item { transition: none; }
}

A segmented control that stays open

This version wraps a hidden radio input inside each label. :has(input:checked) on the label applies the flex-grow style, and because a radio stays checked, the expanded item stays expanded until another is picked.

HTML

<div class="sq-group sq-segmented" role="radiogroup" aria-label="Time range">
  <label class="sq-item"><input type="radio" name="range"> Day</label>
  <label class="sq-item"><input type="radio" name="range" checked> Week</label>
  <label class="sq-item"><input type="radio" name="range"> Month</label>
</div>

CSS

/* adds to the .sq-group and .sq-item rules above */
.sq-segmented input {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
}

.sq-segmented .sq-item {
  text-align: center;
}

/* only the checked item grows here */
.sq-segmented .sq-item:active {
  flex-grow: 1;
  border-radius: 14px;
  color: #f0f0f0;
  background: #0c0c0d;
}

.sq-segmented .sq-item:has(input:checked) {
  flex-grow: 1.7;
  border-radius: 22px;
  color: #16210a;
  background: #b8ff57;
}

.sq-segmented .sq-item:has(input:focus-visible) {
  outline: 2px solid #b8ff57;
  outline-offset: 2px;
}

Understand it

How it works and 6 properties

The neighbors in this demo have no rule targeting them on press. They shrink because flex distributes the row's space by weight: raise one item's flex-grow and the others recalculate their share.

The spring comes from a linear() easing written as eleven sample points.

How it works

Each button starts with flex: 1 1 0, so all three share the row equally. Pressing one sets flex-grow: 1.7 on :active. The flex algorithm recalculates: the active button takes 1.7/3.7 of the available free space, each neighbor takes 1/3.7. The transition on flex-grow animates that shift, and the linear() easing makes it overshoot before settling. The style applies on press and reverts on release.

flex: 1 1 0 sets flex-basis: 0 on every button. Without flex-basis: 0 the three buttons would claim different base widths from their text and the redistribution on press would not work the same way.

The linear() value is eleven progress samples: linear(0, .907, 1.284, 1.081, .93, .962, 1.014, 1.014, .998, .996, 1). The entry 1.284 is what produces the bounce: the button widens past its target, then the remaining samples pull it back. The browser interpolates linearly between each of the eleven points.

The cubic-bezier fallback matters because linear() only arrived in Chrome 113, Firefox 112, and Safari 17.2. The transition shorthand puts the cubic-bezier first, and transition-timing-function follows on its own line to override only the easing on supporting browsers.

The transition-timing-function declaration has one value, not two. CSS repeats a single value across all transitions in the list, so in linear() browsers both flex-grow and border-radius get the spring easing. In fallback browsers that ignore the transition-timing-function line, flex-grow takes cubic-bezier(.34, 1.56, .64, 1) from the shorthand and border-radius takes the shorthand's implicit ease. The color and background changes are not in the transition list and snap instantly in both cases.

Accessibility notes

The main controls are <button> elements with a :focus-visible ring from the demo CSS, but :active clears on release. For a selection that stays, the segmented control variant is the right pattern: radio inputs navigate with arrow keys and carry a checked state.

The :has(input:focus-visible) rule on each label provides a visible focus ring when the keyboard reaches an option. The radio inputs have position: absolute, a height and width of 1px, and opacity: 0 rather than display: none, which keeps them in the accessibility tree.

Mistakes worth avoiding

  • Forgetting flex: 1 1 0. Without it the siblings default to flex-grow: 0 and hold their size while the pressed button expands into the row's free space on its own.
  • Expecting :active to persist. It fires while pressed and clears on release, so the main demo is press feedback only. Use the radio variant instead.
  • Putting linear() directly in the transition shorthand. A browser that does not recognize linear() drops the whole transition declaration.
  • Skipping the reduced-motion rule. The spring is a deliberate motion effect and a user who has requested reduced motion should get no transition at all.
  • Setting min-width too high. The demo uses min-width: 0 to let shrinking neighbors narrow as far as needed. A large min-width can prevent siblings from giving way.

Ship it

4 features in 4 browsers, 4 questions

Browser support

Feature Chrome Firefox Safari Edge
flexbox21286.112
transition455.112
linear() easing11311217.2113
:has()10512115.4105

linear() easing is the constraint: Chrome 113, Firefox 112, and Safari 17.2, with a cubic-bezier in the shorthand for older versions. :has() for the segmented control variant requires Chrome 105, Firefox 121, and Safari 15.4; without it the variant shows no selected option style.

What you can build with it

  • Time range selectors. Day, week, or month in a chart toolbar. Pressing one gives it a tactile grow.
  • View mode toggles. Grid, list, or map, where the active choice expands while the others narrow.
  • Filter groups. A tag row where selecting a category gives it more visual weight without displacing anything else on the page.
  • Quantity feedback. A plus and minus pair where pressing a control grows it momentarily to confirm the tap registered.
  • Segmented controls. Any selection that needs to look more active than a plain highlighted button.

Frequently asked questions

How does the button grow while the others shrink?
Each button has flex: 1 1 0, so they share the row's space by weight. Raising one button's flex-grow on :active increases its weight, and the flex algorithm shrinks the others to compensate.
What is linear() easing?
linear() is a CSS easing function that takes a list of progress sample points and interpolates linearly between each. A sample above 1.0 produces overshoot, which creates the spring effect here.
Why does the button snap back when I release it?
Releasing it resets flex-grow to 1. The segmented control variant uses radio inputs instead, so one option stays open after the pointer lifts.
Does linear() work in all browsers?
linear() arrived in Chrome 113, Firefox 112, and Safari 17.2. Older browsers read the cubic-bezier fallback in the shorthand instead.