20 CSS Pagination14 / 20
Sliding Pill Active Pagination
One pill that slides between five positions instead of five backgrounds blinking on and off. Built on a native radio group, so arrow-key traversal is free, and driven entirely by a custom property the parent selector rewrites — the movement is a single compositor-only translate with a little overshoot.
Published
The code
<section class="pgn-14" aria-label="Sliding Pill Active Indicator Pagination demo">
<div class="pgn-14__stage">
<header class="pgn-14__head">
<p class="pgn-14__eyebrow">Radio group · arrow keys work</p>
<h2 class="pgn-14__title">One pill, five positions</h2>
<p class="pgn-14__sub">Focus any page and press the left or right arrow — the group is a native radio set, so keyboard traversal comes free and the indicator slides instead of blinking.</p>
</header>
<nav class="pgn-14__nav" aria-label="Pagination">
<div class="pgn-14__track" role="none">
<span class="pgn-14__pill" aria-hidden="true"></span>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-1" checked>
<label class="pgn-14__label" for="pgn-14-1">1</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-2">
<label class="pgn-14__label" for="pgn-14-2">2</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-3">
<label class="pgn-14__label" for="pgn-14-3">3</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-4">
<label class="pgn-14__label" for="pgn-14-4">4</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-5">
<label class="pgn-14__label" for="pgn-14-5">5</label>
</div>
</nav>
<p class="pgn-14__readout" role="status" aria-live="polite">
<span class="pgn-14__r" data-i="1">Page 1 · results 1–20</span>
<span class="pgn-14__r" data-i="2">Page 2 · results 21–40</span>
<span class="pgn-14__r" data-i="3">Page 3 · results 41–60</span>
<span class="pgn-14__r" data-i="4">Page 4 · results 61–80</span>
<span class="pgn-14__r" data-i="5">Page 5 · results 81–100</span>
</p>
</div>
</section><section class="pgn-14" aria-label="Sliding Pill Active Indicator Pagination demo">
<div class="pgn-14__stage">
<header class="pgn-14__head">
<p class="pgn-14__eyebrow">Radio group · arrow keys work</p>
<h2 class="pgn-14__title">One pill, five positions</h2>
<p class="pgn-14__sub">Focus any page and press the left or right arrow — the group is a native radio set, so keyboard traversal comes free and the indicator slides instead of blinking.</p>
</header>
<nav class="pgn-14__nav" aria-label="Pagination">
<div class="pgn-14__track" role="none">
<span class="pgn-14__pill" aria-hidden="true"></span>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-1" checked>
<label class="pgn-14__label" for="pgn-14-1">1</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-2">
<label class="pgn-14__label" for="pgn-14-2">2</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-3">
<label class="pgn-14__label" for="pgn-14-3">3</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-4">
<label class="pgn-14__label" for="pgn-14-4">4</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-5">
<label class="pgn-14__label" for="pgn-14-5">5</label>
</div>
</nav>
<p class="pgn-14__readout" role="status" aria-live="polite">
<span class="pgn-14__r" data-i="1">Page 1 · results 1–20</span>
<span class="pgn-14__r" data-i="2">Page 2 · results 21–40</span>
<span class="pgn-14__r" data-i="3">Page 3 · results 41–60</span>
<span class="pgn-14__r" data-i="4">Page 4 · results 61–80</span>
<span class="pgn-14__r" data-i="5">Page 5 · results 81–100</span>
</p>
</div>
</section>.pgn-14 {
--bg: #fafafa;
--surface: #ffffff;
--surface-2: #f0f0f2;
--text: #09090b;
--muted: #71717a;
--line: #e4e4e7;
--acc: #18181b;
--acc-fg: #ffffff;
--ring: #6366f1;
--n: 5;
}
@supports (color: oklch(50% 0 0)) {
.pgn-14 {
--bg: oklch(98.4% .002 286);
--surface: oklch(100% 0 0);
--surface-2: oklch(95.5% .004 286);
--text: oklch(17% .006 286);
--muted: oklch(55% .015 286);
--line: oklch(92% .004 286);
--acc: oklch(22% .008 286);
--acc-fg: oklch(99% 0 0);
--ring: oklch(62% .19 277);
}
}
@media (prefers-color-scheme: dark) {
.pgn-14:not([data-theme="light"]) {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
}
.pgn-14[data-theme="dark"] {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
.pgn-14 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.5rem,5vw,4rem);
background: var(--bg);
color: var(--text);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.pgn-14,
.pgn-14 *,
.pgn-14 *::before,
.pgn-14 *::after {
box-sizing: border-box;
}
.pgn-14__stage {
width: 100%;
max-width: 40rem;
display: grid;
gap: 2rem;
justify-items: center;
text-align: center;
}
.pgn-14__eyebrow {
margin: 0 0 .4rem;
font-size: .6875rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
.pgn-14__title {
margin: 0 0 .55rem;
font-size: clamp(1.4rem,3.2vw,1.9rem);
font-weight: 600;
letter-spacing: -.025em;
}
.pgn-14__sub {
margin: 0;
max-width: 32rem;
font-size: .875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.pgn-14__track {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 3.25rem;
padding: .3rem;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
--i: 0;
}
.pgn-14__pill {
position: absolute;
inset: .3rem auto .3rem .3rem;
width: 3.25rem;
border-radius: 999px;
background: var(--acc);
translate: calc(var(--i) * 3.25rem) 0;
transition: translate .34s cubic-bezier(.34,1.4,.5,1);
}
.pgn-14__radio {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.pgn-14__label {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 2.75rem;
border-radius: 999px;
color: var(--muted);
font-size: .9375rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: color .2s ease;
}
.pgn-14__label:hover {
color: var(--text);
}
.pgn-14:has(#pgn-14-1:checked) .pgn-14__track {
--i: 0;
}
.pgn-14:has(#pgn-14-2:checked) .pgn-14__track {
--i: 1;
}
.pgn-14:has(#pgn-14-3:checked) .pgn-14__track {
--i: 2;
}
.pgn-14:has(#pgn-14-4:checked) .pgn-14__track {
--i: 3;
}
.pgn-14:has(#pgn-14-5:checked) .pgn-14__track {
--i: 4;
}
.pgn-14__radio:checked + .pgn-14__label {
color: var(--acc-fg);
font-weight: 600;
}
.pgn-14__radio:focus-visible + .pgn-14__label {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
.pgn-14__readout {
margin: 0;
font-size: .875rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-height: 1.4em;
}
.pgn-14__r {
display: none;
}
.pgn-14:has(#pgn-14-1:checked) [data-i="1"],
.pgn-14:has(#pgn-14-2:checked) [data-i="2"],
.pgn-14:has(#pgn-14-3:checked) [data-i="3"],
.pgn-14:has(#pgn-14-4:checked) [data-i="4"],
.pgn-14:has(#pgn-14-5:checked) [data-i="5"] {
display: inline;
}
@media (prefers-reduced-motion: reduce) {
.pgn-14 * {
animation: none !important;
transition: none !important;
}
}.pgn-14 {
--bg: #fafafa;
--surface: #ffffff;
--surface-2: #f0f0f2;
--text: #09090b;
--muted: #71717a;
--line: #e4e4e7;
--acc: #18181b;
--acc-fg: #ffffff;
--ring: #6366f1;
--n: 5;
}
@supports (color: oklch(50% 0 0)) {
.pgn-14 {
--bg: oklch(98.4% .002 286);
--surface: oklch(100% 0 0);
--surface-2: oklch(95.5% .004 286);
--text: oklch(17% .006 286);
--muted: oklch(55% .015 286);
--line: oklch(92% .004 286);
--acc: oklch(22% .008 286);
--acc-fg: oklch(99% 0 0);
--ring: oklch(62% .19 277);
}
}
@media (prefers-color-scheme: dark) {
.pgn-14:not([data-theme="light"]) {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
}
.pgn-14[data-theme="dark"] {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
.pgn-14 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.5rem,5vw,4rem);
background: var(--bg);
color: var(--text);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.pgn-14,
.pgn-14 *,
.pgn-14 *::before,
.pgn-14 *::after {
box-sizing: border-box;
}
.pgn-14__stage {
width: 100%;
max-width: 40rem;
display: grid;
gap: 2rem;
justify-items: center;
text-align: center;
}
.pgn-14__eyebrow {
margin: 0 0 .4rem;
font-size: .6875rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
.pgn-14__title {
margin: 0 0 .55rem;
font-size: clamp(1.4rem,3.2vw,1.9rem);
font-weight: 600;
letter-spacing: -.025em;
}
.pgn-14__sub {
margin: 0;
max-width: 32rem;
font-size: .875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.pgn-14__track {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 3.25rem;
padding: .3rem;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
--i: 0;
}
.pgn-14__pill {
position: absolute;
inset: .3rem auto .3rem .3rem;
width: 3.25rem;
border-radius: 999px;
background: var(--acc);
translate: calc(var(--i) * 3.25rem) 0;
transition: translate .34s cubic-bezier(.34,1.4,.5,1);
}
.pgn-14__radio {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.pgn-14__label {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 2.75rem;
border-radius: 999px;
color: var(--muted);
font-size: .9375rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: color .2s ease;
}
.pgn-14__label:hover {
color: var(--text);
}
.pgn-14:has(#pgn-14-1:checked) .pgn-14__track {
--i: 0;
}
.pgn-14:has(#pgn-14-2:checked) .pgn-14__track {
--i: 1;
}
.pgn-14:has(#pgn-14-3:checked) .pgn-14__track {
--i: 2;
}
.pgn-14:has(#pgn-14-4:checked) .pgn-14__track {
--i: 3;
}
.pgn-14:has(#pgn-14-5:checked) .pgn-14__track {
--i: 4;
}
.pgn-14__radio:checked + .pgn-14__label {
color: var(--acc-fg);
font-weight: 600;
}
.pgn-14__radio:focus-visible + .pgn-14__label {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
.pgn-14__readout {
margin: 0;
font-size: .875rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-height: 1.4em;
}
.pgn-14__r {
display: none;
}
.pgn-14:has(#pgn-14-1:checked) [data-i="1"],
.pgn-14:has(#pgn-14-2:checked) [data-i="2"],
.pgn-14:has(#pgn-14-3:checked) [data-i="3"],
.pgn-14:has(#pgn-14-4:checked) [data-i="4"],
.pgn-14:has(#pgn-14-5:checked) [data-i="5"] {
display: inline;
}
@media (prefers-reduced-motion: reduce) {
.pgn-14 * {
animation: none !important;
transition: none !important;
}
}Here's a working CSS Pagination 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: Sliding Pill Active Pagination
Source: https://codefronts.com/navigation/css-pagination/sliding-pill-active-pagination/
One pill that slides between five positions instead of five backgrounds blinking on and off. Built on a native radio group, so arrow-key traversal is free, and driven entirely by a custom property the parent selector rewrites — the movement is a single compositor-only translate with a little overshoot.
## HTML
```html
<section class="pgn-14" aria-label="Sliding Pill Active Indicator Pagination demo">
<div class="pgn-14__stage">
<header class="pgn-14__head">
<p class="pgn-14__eyebrow">Radio group · arrow keys work</p>
<h2 class="pgn-14__title">One pill, five positions</h2>
<p class="pgn-14__sub">Focus any page and press the left or right arrow — the group is a native radio set, so keyboard traversal comes free and the indicator slides instead of blinking.</p>
</header>
<nav class="pgn-14__nav" aria-label="Pagination">
<div class="pgn-14__track" role="none">
<span class="pgn-14__pill" aria-hidden="true"></span>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-1" checked>
<label class="pgn-14__label" for="pgn-14-1">1</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-2">
<label class="pgn-14__label" for="pgn-14-2">2</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-3">
<label class="pgn-14__label" for="pgn-14-3">3</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-4">
<label class="pgn-14__label" for="pgn-14-4">4</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-5">
<label class="pgn-14__label" for="pgn-14-5">5</label>
</div>
</nav>
<p class="pgn-14__readout" role="status" aria-live="polite">
<span class="pgn-14__r" data-i="1">Page 1 · results 1–20</span>
<span class="pgn-14__r" data-i="2">Page 2 · results 21–40</span>
<span class="pgn-14__r" data-i="3">Page 3 · results 41–60</span>
<span class="pgn-14__r" data-i="4">Page 4 · results 61–80</span>
<span class="pgn-14__r" data-i="5">Page 5 · results 81–100</span>
</p>
</div>
</section>
```
## CSS
```css
.pgn-14 {
--bg: #fafafa;
--surface: #ffffff;
--surface-2: #f0f0f2;
--text: #09090b;
--muted: #71717a;
--line: #e4e4e7;
--acc: #18181b;
--acc-fg: #ffffff;
--ring: #6366f1;
--n: 5;
}
@supports (color: oklch(50% 0 0)) {
.pgn-14 {
--bg: oklch(98.4% .002 286);
--surface: oklch(100% 0 0);
--surface-2: oklch(95.5% .004 286);
--text: oklch(17% .006 286);
--muted: oklch(55% .015 286);
--line: oklch(92% .004 286);
--acc: oklch(22% .008 286);
--acc-fg: oklch(99% 0 0);
--ring: oklch(62% .19 277);
}
}
@media (prefers-color-scheme: dark) {
.pgn-14:not([data-theme="light"]) {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
}
.pgn-14[data-theme="dark"] {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
.pgn-14 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.5rem,5vw,4rem);
background: var(--bg);
color: var(--text);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.pgn-14,
.pgn-14 *,
.pgn-14 *::before,
.pgn-14 *::after {
box-sizing: border-box;
}
.pgn-14__stage {
width: 100%;
max-width: 40rem;
display: grid;
gap: 2rem;
justify-items: center;
text-align: center;
}
.pgn-14__eyebrow {
margin: 0 0 .4rem;
font-size: .6875rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
.pgn-14__title {
margin: 0 0 .55rem;
font-size: clamp(1.4rem,3.2vw,1.9rem);
font-weight: 600;
letter-spacing: -.025em;
}
.pgn-14__sub {
margin: 0;
max-width: 32rem;
font-size: .875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.pgn-14__track {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 3.25rem;
padding: .3rem;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
--i: 0;
}
.pgn-14__pill {
position: absolute;
inset: .3rem auto .3rem .3rem;
width: 3.25rem;
border-radius: 999px;
background: var(--acc);
translate: calc(var(--i) * 3.25rem) 0;
transition: translate .34s cubic-bezier(.34,1.4,.5,1);
}
.pgn-14__radio {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.pgn-14__label {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 2.75rem;
border-radius: 999px;
color: var(--muted);
font-size: .9375rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: color .2s ease;
}
.pgn-14__label:hover {
color: var(--text);
}
.pgn-14:has(#pgn-14-1:checked) .pgn-14__track {
--i: 0;
}
.pgn-14:has(#pgn-14-2:checked) .pgn-14__track {
--i: 1;
}
.pgn-14:has(#pgn-14-3:checked) .pgn-14__track {
--i: 2;
}
.pgn-14:has(#pgn-14-4:checked) .pgn-14__track {
--i: 3;
}
.pgn-14:has(#pgn-14-5:checked) .pgn-14__track {
--i: 4;
}
.pgn-14__radio:checked + .pgn-14__label {
color: var(--acc-fg);
font-weight: 600;
}
.pgn-14__radio:focus-visible + .pgn-14__label {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
.pgn-14__readout {
margin: 0;
font-size: .875rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-height: 1.4em;
}
.pgn-14__r {
display: none;
}
.pgn-14:has(#pgn-14-1:checked) [data-i="1"],
.pgn-14:has(#pgn-14-2:checked) [data-i="2"],
.pgn-14:has(#pgn-14-3:checked) [data-i="3"],
.pgn-14:has(#pgn-14-4:checked) [data-i="4"],
.pgn-14:has(#pgn-14-5:checked) [data-i="5"] {
display: inline;
}
@media (prefers-reduced-motion: reduce) {
.pgn-14 * {
animation: none !important;
transition: none !important;
}
}
```Here's a working CSS Pagination 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: Sliding Pill Active Pagination
Source: https://codefronts.com/navigation/css-pagination/sliding-pill-active-pagination/
One pill that slides between five positions instead of five backgrounds blinking on and off. Built on a native radio group, so arrow-key traversal is free, and driven entirely by a custom property the parent selector rewrites — the movement is a single compositor-only translate with a little overshoot.
## HTML
```html
<section class="pgn-14" aria-label="Sliding Pill Active Indicator Pagination demo">
<div class="pgn-14__stage">
<header class="pgn-14__head">
<p class="pgn-14__eyebrow">Radio group · arrow keys work</p>
<h2 class="pgn-14__title">One pill, five positions</h2>
<p class="pgn-14__sub">Focus any page and press the left or right arrow — the group is a native radio set, so keyboard traversal comes free and the indicator slides instead of blinking.</p>
</header>
<nav class="pgn-14__nav" aria-label="Pagination">
<div class="pgn-14__track" role="none">
<span class="pgn-14__pill" aria-hidden="true"></span>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-1" checked>
<label class="pgn-14__label" for="pgn-14-1">1</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-2">
<label class="pgn-14__label" for="pgn-14-2">2</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-3">
<label class="pgn-14__label" for="pgn-14-3">3</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-4">
<label class="pgn-14__label" for="pgn-14-4">4</label>
<input class="pgn-14__radio" type="radio" name="pgn-14" id="pgn-14-5">
<label class="pgn-14__label" for="pgn-14-5">5</label>
</div>
</nav>
<p class="pgn-14__readout" role="status" aria-live="polite">
<span class="pgn-14__r" data-i="1">Page 1 · results 1–20</span>
<span class="pgn-14__r" data-i="2">Page 2 · results 21–40</span>
<span class="pgn-14__r" data-i="3">Page 3 · results 41–60</span>
<span class="pgn-14__r" data-i="4">Page 4 · results 61–80</span>
<span class="pgn-14__r" data-i="5">Page 5 · results 81–100</span>
</p>
</div>
</section>
```
## CSS
```css
.pgn-14 {
--bg: #fafafa;
--surface: #ffffff;
--surface-2: #f0f0f2;
--text: #09090b;
--muted: #71717a;
--line: #e4e4e7;
--acc: #18181b;
--acc-fg: #ffffff;
--ring: #6366f1;
--n: 5;
}
@supports (color: oklch(50% 0 0)) {
.pgn-14 {
--bg: oklch(98.4% .002 286);
--surface: oklch(100% 0 0);
--surface-2: oklch(95.5% .004 286);
--text: oklch(17% .006 286);
--muted: oklch(55% .015 286);
--line: oklch(92% .004 286);
--acc: oklch(22% .008 286);
--acc-fg: oklch(99% 0 0);
--ring: oklch(62% .19 277);
}
}
@media (prefers-color-scheme: dark) {
.pgn-14:not([data-theme="light"]) {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
}
.pgn-14[data-theme="dark"] {
--bg: #09090b;
--surface: #151518;
--surface-2: #202024;
--text: #fafafa;
--muted: #a1a1aa;
--line: #27272a;
--acc: #fafafa;
--acc-fg: #09090b;
--ring: #818cf8;
}
.pgn-14 {
width: 100%;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: clamp(1.5rem,5vw,4rem);
background: var(--bg);
color: var(--text);
font-family: system-ui,-apple-system,"Segoe UI",sans-serif;
-webkit-font-smoothing: antialiased;
}
.pgn-14,
.pgn-14 *,
.pgn-14 *::before,
.pgn-14 *::after {
box-sizing: border-box;
}
.pgn-14__stage {
width: 100%;
max-width: 40rem;
display: grid;
gap: 2rem;
justify-items: center;
text-align: center;
}
.pgn-14__eyebrow {
margin: 0 0 .4rem;
font-size: .6875rem;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--muted);
}
.pgn-14__title {
margin: 0 0 .55rem;
font-size: clamp(1.4rem,3.2vw,1.9rem);
font-weight: 600;
letter-spacing: -.025em;
}
.pgn-14__sub {
margin: 0;
max-width: 32rem;
font-size: .875rem;
line-height: 1.6;
color: var(--muted);
text-wrap: pretty;
}
.pgn-14__track {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 3.25rem;
padding: .3rem;
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 999px;
--i: 0;
}
.pgn-14__pill {
position: absolute;
inset: .3rem auto .3rem .3rem;
width: 3.25rem;
border-radius: 999px;
background: var(--acc);
translate: calc(var(--i) * 3.25rem) 0;
transition: translate .34s cubic-bezier(.34,1.4,.5,1);
}
.pgn-14__radio {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.pgn-14__label {
position: relative;
z-index: 1;
display: grid;
place-items: center;
height: 2.75rem;
border-radius: 999px;
color: var(--muted);
font-size: .9375rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: color .2s ease;
}
.pgn-14__label:hover {
color: var(--text);
}
.pgn-14:has(#pgn-14-1:checked) .pgn-14__track {
--i: 0;
}
.pgn-14:has(#pgn-14-2:checked) .pgn-14__track {
--i: 1;
}
.pgn-14:has(#pgn-14-3:checked) .pgn-14__track {
--i: 2;
}
.pgn-14:has(#pgn-14-4:checked) .pgn-14__track {
--i: 3;
}
.pgn-14:has(#pgn-14-5:checked) .pgn-14__track {
--i: 4;
}
.pgn-14__radio:checked + .pgn-14__label {
color: var(--acc-fg);
font-weight: 600;
}
.pgn-14__radio:focus-visible + .pgn-14__label {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
.pgn-14__readout {
margin: 0;
font-size: .875rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
min-height: 1.4em;
}
.pgn-14__r {
display: none;
}
.pgn-14:has(#pgn-14-1:checked) [data-i="1"],
.pgn-14:has(#pgn-14-2:checked) [data-i="2"],
.pgn-14:has(#pgn-14-3:checked) [data-i="3"],
.pgn-14:has(#pgn-14-4:checked) [data-i="4"],
.pgn-14:has(#pgn-14-5:checked) [data-i="5"] {
display: inline;
}
@media (prefers-reduced-motion: reduce) {
.pgn-14 * {
animation: none !important;
transition: none !important;
}
}
```How this works
Every page gets an identical grid column, so the pill's position is just a multiple of that width:
.pgn-14__track { grid-auto-columns: 3.25rem; --i: 0; }
.pgn-14__pill { width: 3.25rem;
translate: calc(var(--i) * 3.25rem) 0;
transition: translate .34s cubic-bezier(.34,1.4,.5,1); }Five :has() rules on the root set the index from whichever radio is checked:
.pgn-14:has(#pgn-14-3:checked) .pgn-14__track { --i: 2; }Because the inputs are real radios sharing a name, left/right arrows move between pages with no keydown handler, and :checked + label recolours the active number. The pill sits behind the labels through z-index: 1 on the label, not a negative index on the pill.
Make it yours
- Change the column width in one place and update the pill width and multiplier together — or hoist it to a
--wtoken used by all three. - Add a page by adding an input, a label and one more
:has()rule. - The overshoot in
cubic-bezier(.34, 1.4, .5, 1)is the personality; flatten it toease-outfor a corporate feel. - For a vertical rail, switch
grid-auto-flowtorowand translate on the Y axis instead.
Gotchas — read before shipping
- Pill width and grid column must match exactly, or drift accumulates across positions — the number one bug in sliding indicators.
- Keep the radios clipped rather than
display: none, or arrow-key navigation and focus disappear. - Without the label's
z-indexthe numbers vanish under the fill. - Radio groups share a
name, so two instances on one page collide. - Under reduced motion the slide is disabled — the state is still readable from the fill and the readout, which is exactly the point.
Browser support
| Chrome | Safari | Firefox | Edge |
|---|---|---|---|
| 114+ | 17.5+ | 121+ | 114+ |
Floor set by :has(), oklch(), text-wrap as this demo is written. The core technique itself goes back further — Chrome 105+.
:has() is the floor (Chrome 105, Safari 15.4, Firefox 121) and drives the indicator position. Without it the pill stays under page one while the radios still work — degrade by adding a :checked outline fallback.