25 CSS Button Groups03 / 25

Pure CSSMIT licensed

CSS View Transitions Toggle Button Group with Morphing Thumb (Shared-Element FLIP)

A toggle button group where the active highlight doesn't slide — it morphs. Powered by the View Transitions API, the highlight is a shared element that the browser FLIP-animates from the old button to the new one, smoothly stretching across size and position changes. The forward-looking 'CSS View Transitions button group morphing highlight' pattern for 2027 UIs.

Published

Live Demo
Try it

The code

<section class="btn-03" aria-label="View transitions morphing toggle group demo">
  <div class="btn-03__wrap">
    <p class="btn-03__label">Workspace theme</p>
    <div class="btn-03__group" id="btn-03-group" role="radiogroup" aria-label="Workspace theme">
      <button type="button" class="btn-03__btn" role="radio" aria-checked="true" tabindex="0"><span class="btn-03__hl"></span><span class="btn-03__t">Focus</span></button>
      <button type="button" class="btn-03__btn" role="radio" aria-checked="false" tabindex="-1"><span class="btn-03__hl"></span><span class="btn-03__t">Comfortable</span></button>
      <button type="button" class="btn-03__btn" role="radio" aria-checked="false" tabindex="-1"><span class="btn-03__hl"></span><span class="btn-03__t">Compact</span></button>
      <button type="button" class="btn-03__btn" role="radio" aria-checked="false" tabindex="-1"><span class="btn-03__hl"></span><span class="btn-03__t">Zen</span></button>
    </div>
  </div>
</section>
.btn-03,
.btn-03 *,
.btn-03 *::before,
.btn-03 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.btn-03 {
  --accent: oklch(0.5 0.22 265);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(160deg,#f4f5fa,#e7e9f3);
  padding: 44px 20px;
}

.btn-03__wrap {
  width: min(560px,100%);
}

.btn-03__label {
  font-size: 12px;
  font-weight: 800;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 14px 4px;
}

.btn-03__group {
  display: inline-flex;
  gap: 6px;
  padding: 6px;
  background: #fff;
  border: 1px solid #e2e4f0;
  border-radius: 16px;
  box-shadow: 0 16px 40px -26px rgba(40,30,120,.5);
}

.btn-03__btn {
  position: relative;
  border: none;
  background: none;
  min-height: 46px;
  padding: 0 22px;
  border-radius: 11px;
  font-size: 15px;
  font-weight: 700;
  color: #5a5f80;
  cursor: pointer;
  overflow: hidden;
  transition: color .2s;
}

.btn-03__hl {
  position: absolute;
  inset: 0;
  border-radius: 11px;
  background: var(--accent);
  opacity: 0;
}

.btn-03__t {
  position: relative;
  z-index: 1;
}

.btn-03__btn[aria-checked=true] {
  color: #fff;
}

.btn-03__btn[aria-checked=true] .btn-03__hl {
  opacity: 1;
  view-transition-name: btn-03-thumb;
  box-shadow: 0 6px 16px -6px var(--accent);
}

.btn-03__btn:not([aria-checked=true]):hover {
  color: #2a2e50;
}

.btn-03__btn:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

::view-transition-group(btn-03-thumb) {
  animation-duration: .45s;
  animation-timing-function: cubic-bezier(.34,1.25,.4,1);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(btn-03-thumb) {
    animation-duration: 1ms;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Button Group from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS View Transitions Toggle Button Group with Morphing Thumb (Shared-Element FLIP)
Source: https://codefronts.com/components/css-button-groups/css-view-transitions-toggle-button-group-with-morphing-thumb-shared-element-flip/

A toggle button group where the active highlight doesn't slide — it morphs. Powered by the View Transitions API, the highlight is a shared element that the browser FLIP-animates from the old button to the new one, smoothly stretching across size and position changes. The forward-looking 'CSS View Transitions button group morphing highlight' pattern for 2027 UIs.
## HTML
```html
<section class="btn-03" aria-label="View transitions morphing toggle group demo">
  <div class="btn-03__wrap">
    <p class="btn-03__label">Workspace theme</p>
    <div class="btn-03__group" id="btn-03-group" role="radiogroup" aria-label="Workspace theme">
      <button type="button" class="btn-03__btn" role="radio" aria-checked="true" tabindex="0"><span class="btn-03__hl"></span><span class="btn-03__t">Focus</span></button>
      <button type="button" class="btn-03__btn" role="radio" aria-checked="false" tabindex="-1"><span class="btn-03__hl"></span><span class="btn-03__t">Comfortable</span></button>
      <button type="button" class="btn-03__btn" role="radio" aria-checked="false" tabindex="-1"><span class="btn-03__hl"></span><span class="btn-03__t">Compact</span></button>
      <button type="button" class="btn-03__btn" role="radio" aria-checked="false" tabindex="-1"><span class="btn-03__hl"></span><span class="btn-03__t">Zen</span></button>
    </div>
  </div>
</section>
```
## CSS
```css
.btn-03,
.btn-03 *,
.btn-03 *::before,
.btn-03 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.btn-03 {
  --accent: oklch(0.5 0.22 265);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(160deg,#f4f5fa,#e7e9f3);
  padding: 44px 20px;
}

.btn-03__wrap {
  width: min(560px,100%);
}

.btn-03__label {
  font-size: 12px;
  font-weight: 800;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 14px 4px;
}

.btn-03__group {
  display: inline-flex;
  gap: 6px;
  padding: 6px;
  background: #fff;
  border: 1px solid #e2e4f0;
  border-radius: 16px;
  box-shadow: 0 16px 40px -26px rgba(40,30,120,.5);
}

.btn-03__btn {
  position: relative;
  border: none;
  background: none;
  min-height: 46px;
  padding: 0 22px;
  border-radius: 11px;
  font-size: 15px;
  font-weight: 700;
  color: #5a5f80;
  cursor: pointer;
  overflow: hidden;
  transition: color .2s;
}

.btn-03__hl {
  position: absolute;
  inset: 0;
  border-radius: 11px;
  background: var(--accent);
  opacity: 0;
}

.btn-03__t {
  position: relative;
  z-index: 1;
}

.btn-03__btn[aria-checked=true] {
  color: #fff;
}

.btn-03__btn[aria-checked=true] .btn-03__hl {
  opacity: 1;
  view-transition-name: btn-03-thumb;
  box-shadow: 0 6px 16px -6px var(--accent);
}

.btn-03__btn:not([aria-checked=true]):hover {
  color: #2a2e50;
}

.btn-03__btn:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

::view-transition-group(btn-03-thumb) {
  animation-duration: .45s;
  animation-timing-function: cubic-bezier(.34,1.25,.4,1);
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(btn-03-thumb) {
    animation-duration: 1ms;
  }
}
```

How this works

Each button owns a highlight layer, but only the active one carries view-transition-name:btn-03-thumb. When selection changes, a ~20-line accessible radiogroup helper wraps the state update in document.startViewTransition(). The browser snapshots the old highlight and the new one, and because they share a transition name it treats them as the same element — morphing position AND size (a real shared-element transition, not a CSS translate).

The result reads like the highlight liquid-morphs between buttons of different widths, something a fixed-width sliding thumb can't do. Keyboard arrow keys and roving tabindex keep it a proper radiogroup; without the API it degrades to an instant, still-correct toggle.

.btn[aria-checked=true] .hl{ view-transition-name:btn-03-thumb }
document.startViewTransition(() => setActive(btn));  // browser FLIP-morphs the highlight

Techniques used: View Transitions API shared-element morph (position + size FLIP) · single view-transition-name on the active highlight only · accessible radiogroup with roving tabindex + arrow keys · ::view-transition-group custom easing · reduced-motion instant fallback · progressive enhancement (works with no API)

Make it yours

  • Morph feel = the ::view-transition-group(btn-03-thumb) animation duration/easing.
  • Highlight color = --accent.
  • Add options freely — variable widths are exactly what the morph shows off.
  • Swap to a pill or underline highlight; the shared-element morph adapts.

Gotchas — read before shipping

  • A view-transition-name must be unique per snapshot — keep it ONLY on the active highlight, never on all of them.
  • The View Transitions API is Chrome/Safari; guard with if (document.startViewTransition) and fall back to an instant update (done here).
  • Keep it a real radiogroup: roving tabindex + arrow keys, one Tab stop — don't lose keyboard operability for the effect.
  • Neutralize the transition under prefers-reduced-motion by zeroing the ::view-transition-group animation.

Browser support

ChromeSafariFirefoxEdge
111+18+no111+

Same-document View Transitions ship in Chrome 111+ and Safari 18+; Firefox not yet. It's a progressive enhancement — the group works everywhere and only gains the morph where supported.

Techniques used in this demo

Search CodeFronts

Loading…