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 codeBuild 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 toflex-grow: 0and hold their size while the pressed button expands into the row's free space on its own. - Expecting
:activeto 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 thetransitionshorthand. A browser that does not recognizelinear()drops the wholetransitiondeclaration. - 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-widthtoo high. The demo usesmin-width: 0to let shrinking neighbors narrow as far as needed. A largemin-widthcan prevent siblings from giving way.
Ship it
4 features in 4 browsers, 4 questions
Browser support
| Feature | ||||
|---|---|---|---|---|
flexbox | 21 | 28 | 6.1 | 12 |
transition | 4 | 5 | 5.1 | 12 |
linear() easing | 113 | 112 | 17.2 | 113 |
:has() | 105 | 121 | 15.4 | 105 |
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?
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?
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.