25 CSS Button Groups08 / 25

Pure CSSMIT licensed

CSS Pill Radio Button Group with Sliding Indicator (Filter Pills, Pure CSS)

A row of pill-shaped filter buttons — All / Design / Engineering / Growth — with a single highlight that slides between the selected pill. The most-searched 'CSS pill radio button group sliding indicator' for content filters and category tabs, built on real radio inputs so it's keyboard-navigable and announces selection state out of the box.

Published

Live Demo
Try it

The code

<section class="btn-08" aria-label="Pill radio button group demo">
  <div class="btn-08__wrap">
    <p class="btn-08__label">Browse by team</p>
    <div class="btn-08__pills" role="group" aria-label="Filter by team" style="--x:0px;--w:60px">
      <input type="radio" name="btn-08" id="btn-08-1" class="btn-08__in" checked>
      <label for="btn-08-1" class="btn-08__pill" style="--x:4px;--w:56px">All</label>
      <input type="radio" name="btn-08" id="btn-08-2" class="btn-08__in">
      <label for="btn-08-2" class="btn-08__pill" style="--x:64px;--w:92px">Design</label>
      <input type="radio" name="btn-08" id="btn-08-3" class="btn-08__in">
      <label for="btn-08-3" class="btn-08__pill" style="--x:160px;--w:126px">Engineering</label>
      <input type="radio" name="btn-08" id="btn-08-4" class="btn-08__in">
      <label for="btn-08-4" class="btn-08__pill" style="--x:290px;--w:82px">Growth</label>
      <span class="btn-08__ind" aria-hidden="true"></span>
    </div>
  </div>
</section>
.btn-08,
.btn-08 *,
.btn-08 *::before,
.btn-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.btn-08 {
  --accent: oklch(0.68 0.2 350);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: radial-gradient(130% 130% at 20% 0%,#2a1030,#160820);
  padding: 44px 20px;
}

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

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

.btn-08__pills {
  position: relative;
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0;
  padding: 4px;
  background: rgba(255,255,255,.05);
  border: 1px solid rgba(255,255,255,.12);
  border-radius: 999px;
  backdrop-filter: blur(6px);
}

.btn-08__in {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.btn-08__pill {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 42px;
  padding: 0 20px;
  font-size: 14.5px;
  font-weight: 650;
  color: rgba(255,255,255,.7);
  cursor: pointer;
  border-radius: 999px;
  transition: color .25s;
}

.btn-08__ind {
  position: absolute;
  z-index: 0;
  top: 4px;
  left: 0;
  height: calc(100% - 8px);
  width: var(--w);
  translate: var(--x) 0;
  background: var(--accent);
  border-radius: 999px;
  box-shadow: 0 4px 18px -4px var(--accent);
  transition: translate .4s cubic-bezier(.34,1.3,.5,1),width .4s cubic-bezier(.34,1.3,.5,1);
}

.btn-08__pills:has(#btn-08-1:checked) {
  --x: 4px;
  --w: 56px;
}

.btn-08__pills:has(#btn-08-2:checked) {
  --x: 64px;
  --w: 92px;
}

.btn-08__pills:has(#btn-08-3:checked) {
  --x: 160px;
  --w: 126px;
}

.btn-08__pills:has(#btn-08-4:checked) {
  --x: 290px;
  --w: 82px;
}

.btn-08__in:checked + .btn-08__pill {
  color: #fff;
}

.btn-08__in:focus-visible + .btn-08__pill {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .btn-08__ind,
    .btn-08__pill {
    transition: none;
  }
}
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 Pill Radio Button Group with Sliding Indicator (Filter Pills, Pure CSS)
Source: https://codefronts.com/components/css-button-groups/css-pill-radio-button-group-with-sliding-indicator-filter-pills-pure-css/

A row of pill-shaped filter buttons — All / Design / Engineering / Growth — with a single highlight that slides between the selected pill. The most-searched 'CSS pill radio button group sliding indicator' for content filters and category tabs, built on real radio inputs so it's keyboard-navigable and announces selection state out of the box.
## HTML
```html
<section class="btn-08" aria-label="Pill radio button group demo">
  <div class="btn-08__wrap">
    <p class="btn-08__label">Browse by team</p>
    <div class="btn-08__pills" role="group" aria-label="Filter by team" style="--x:0px;--w:60px">
      <input type="radio" name="btn-08" id="btn-08-1" class="btn-08__in" checked>
      <label for="btn-08-1" class="btn-08__pill" style="--x:4px;--w:56px">All</label>
      <input type="radio" name="btn-08" id="btn-08-2" class="btn-08__in">
      <label for="btn-08-2" class="btn-08__pill" style="--x:64px;--w:92px">Design</label>
      <input type="radio" name="btn-08" id="btn-08-3" class="btn-08__in">
      <label for="btn-08-3" class="btn-08__pill" style="--x:160px;--w:126px">Engineering</label>
      <input type="radio" name="btn-08" id="btn-08-4" class="btn-08__in">
      <label for="btn-08-4" class="btn-08__pill" style="--x:290px;--w:82px">Growth</label>
      <span class="btn-08__ind" aria-hidden="true"></span>
    </div>
  </div>
</section>
```
## CSS
```css
.btn-08,
.btn-08 *,
.btn-08 *::before,
.btn-08 *::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

.btn-08 {
  --accent: oklch(0.68 0.2 350);
  font-family: 'Segoe UI',system-ui,sans-serif;
  width: 100%;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: radial-gradient(130% 130% at 20% 0%,#2a1030,#160820);
  padding: 44px 20px;
}

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

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

.btn-08__pills {
  position: relative;
  display: inline-flex;
  flex-wrap: wrap;
  gap: 0;
  padding: 4px;
  background: rgba(255,255,255,.05);
  border: 1px solid rgba(255,255,255,.12);
  border-radius: 999px;
  backdrop-filter: blur(6px);
}

.btn-08__in {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
}

.btn-08__pill {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 42px;
  padding: 0 20px;
  font-size: 14.5px;
  font-weight: 650;
  color: rgba(255,255,255,.7);
  cursor: pointer;
  border-radius: 999px;
  transition: color .25s;
}

.btn-08__ind {
  position: absolute;
  z-index: 0;
  top: 4px;
  left: 0;
  height: calc(100% - 8px);
  width: var(--w);
  translate: var(--x) 0;
  background: var(--accent);
  border-radius: 999px;
  box-shadow: 0 4px 18px -4px var(--accent);
  transition: translate .4s cubic-bezier(.34,1.3,.5,1),width .4s cubic-bezier(.34,1.3,.5,1);
}

.btn-08__pills:has(#btn-08-1:checked) {
  --x: 4px;
  --w: 56px;
}

.btn-08__pills:has(#btn-08-2:checked) {
  --x: 64px;
  --w: 92px;
}

.btn-08__pills:has(#btn-08-3:checked) {
  --x: 160px;
  --w: 126px;
}

.btn-08__pills:has(#btn-08-4:checked) {
  --x: 290px;
  --w: 82px;
}

.btn-08__in:checked + .btn-08__pill {
  color: #fff;
}

.btn-08__in:focus-visible + .btn-08__pill {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .btn-08__ind,
    .btn-08__pill {
    transition: none;
  }
}
```

How this works

Each pill is a label bound to a hidden radio sharing one name. Unlike the fixed-width segmented control (demo 02), these pills are content-sized, so a proportional translate won't work — instead the sliding highlight is a ::before layer that uses :has() to read which pill is checked and animates left+width to match. For a pure-CSS demo the highlight sits behind the active pill via stacking; in production the same effect drives from a tiny measure-on-select helper if pills are dynamic.

Here we keep it fully declarative: a ::after underbar-pill is placed per-selection with per-radio rules, giving a crisp slide with no measurement. Text color inverts on the active pill and the whole group has one focus ring per pill via :focus-visible.

.pills:has(#p2:checked){ --x:var(--x2); --w:var(--w2) }
.pills__ind{ translate:var(--x) 0; width:var(--w) }

Techniques used: content-sized pills with hidden radios · CSS-variable driven indicator position/width · :has() selection projection · color inversion on active pill · wrap-friendly flex layout

Make it yours

  • Set each pill's --x/--w pair to its measured offset/width (shown inline for the demo).
  • --accent is the indicator fill.
  • Let pills wrap to two rows on mobile by keeping flex-wrap:wrap and hiding the slide below a breakpoint.
  • Turn it into a multi-select tag row by switching radios to checkboxes (see demo 11 for the glow variant).

Gotchas — read before shipping

  • A moving indicator across variable-width pills needs known offsets; for user-generated pill text, measure widths with a few lines of JS rather than hard-coding.
  • Keep radios grouped by name so arrow keys cycle selection.
  • Ensure the active pill's text meets 4.5:1 contrast against the indicator fill — test both light and dark accents.
  • If pills wrap, disable the horizontal slide on the wrapped breakpoint to avoid the indicator flying across rows.

Browser support

ChromeSafariFirefoxEdge
111+15.4+121+111+

Floor set by :has(), oklch(), backdrop-filter as this demo is written. The core technique itself goes back further — Chrome 105+.

:has() Baseline 2023. The radio + label pill row degrades to a normal selectable group without the sliding indicator on older engines.

Techniques used in this demo

Search CodeFronts

Loading…