Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Pager pill

A pill of dots that stretch from page to page, with a rubber band at either end, driving a small carousel.

pager-pill/pager-pill.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A short run of cards read one at a time: onboarding tips, a feature tour, the steps of a how-to.
  • Three to seven pages, few enough that a dot each still counts them at a glance.
  • Where both a tap on a button and a swipe across the cards should work, on touch and on a trackpad.
  • A small panel or a sheet where a full pager with numbers would be too heavy.

When not to use

  • Long or open-ended lists. Use a scrolling list, or numbered pagination for pages of results.
  • Content the reader must compare side by side: a carousel hides all but one card. Use a grid.
  • A required sequence with validation between steps. Use a stepper that names each step.
  • A hero banner that turns by itself. Content that moves away while being read is hard to follow.

You can add this component with the shadcn CLI or by hand:

Into components/pager-pill of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/pager-pill.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

plant-tips.tsx
import { PagerPill } from './pager-pill';import { TIPS } from './tips';export function PlantTips() {  return <PagerPill tips={TIPS} defaultTheme="green" autoplay={false} />;}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

Previous and next buttons sit round a row of dots, and the current page’s dot is a bar. It stretches towards the next dot and draws its tail in after it, a soft glint riding its leading end. At either end the button dims and a push meets a little rubber band; drag or swipe the cards above and the dots follow, in yellow or green.

  • One number drives everything: the position, in pages. A spring carries it to the chosen page (motion.ts), and every frame while it moves the cards and dots are painted from it with transforms, outside React. Nothing runs at rest.
  • Between two pages the bar’s leading end runs ahead on an ease-out while its tail lags on an ease-in, so it stretches as it travels. The bar is two round ends and a middle stretched with scaleX, so its length changes with transforms alone.
  • Dragging moves the position directly, and past an end the cards give less and less. On release they land on one of the two pages either side, the one the drag’s speed heads for, and the spring keeps that speed.
  • A press past either end gives the position a push the spring brings back: the cards nudge about 20px, the bar stretches towards the edge, and both settle.
  • The theme colours are registered custom properties (@property, prefixed --pager-), so switching between yellow and green cross-fades every colour that uses them.
  • The stage and card are light-dark() tokens on the root, so they follow the page’s color-scheme, or the colorScheme prop. The yellow and green themes keep their accent colours and only their tints change at night; theme colours are registered properties, so a change of theme or of scheme cross-fades.
PropTypeDefaultDescription
tipsTip[]TIPSThe cards, in order: an id, a label, a title, a line of text and a 48 × 48 drawing each.
defaultTheme'yellow' | 'green''yellow'The colours it starts in.
defaultPagenumber—The card it opens on, from 0; a fraction (2.5) holds the cards and dots halfway between two pages until the next input. Turns the demo off.
onPageChange(page: number) => void—Called with the page it heads for, from 0, whenever the page changes.
onThemeChange(theme: 'yellow' | 'green') => void—Called with the new colours whenever they change.
autoplaybooleantruePages through the cards, bumps the end and changes colour with a demo cursor while on screen and untouched.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

pager-pill.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type KeyboardEvent,  type PointerEvent,  type SVGProps,} from 'react';import { DemoCursor, glideTo, hideCursor, pressWith } from './demo-cursor';import { cardLook, dotsStyles, dotsWidth, rubberBand, springStep } from './motion';import { TIPS, type Tip } from './tips';import { useAutoplay } from './use-autoplay';import styles from './pager-pill.module.css';/* * Pager pill: a pill with a previous and a next button round a row of dots, driving a small * carousel of cards. The current page's dot is a bar: as the page changes, the bar stretches * towards the next dot and draws its tail in after it, a glint riding its leading end. At either
pager-pill.module.css
/* Pager pill (pager-pill.tsx): a light stage with the theme switch, the cards, and the pill, or at   night a near-black stage with dark grey cards. Sizes are in em: the font-size follows the   component's width (a container), from 14px on a phone to 16.5px. *//* The theme's colours, registered so that they can transition: a change of theme cross-fades   everything that uses them. Prefixed, as @property names are global. */@property --pager-accent {  syntax: '<color>';  inherits: true;  initial-value: #f2c230;}@property --pager-on-accent {  syntax: '<color>';  inherits: true;  initial-value: #4d3a00;}@property --pager-soft {  syntax: '<color>';  inherits: true;  initial-value: #fbf0c8;}@property --pager-dim {  syntax: '<color>';  inherits: true;
motion.ts
/* * The pager's motion, as pure functions of the position: a float counted in pages (1.5 is halfway * from the second card to the third). The component moves the position with a spring and paints * the cards and the dots from it, every frame while it moves. *//** The spring that carries the position to its page: it overshoots a touch, then settles. */const STIFFNESS = 230;const DAMPING = 24;/** One step of the spring, `dt` seconds long, from `position` and `velocity` (pages per second). */export function springStep(position: number, velocity: number, target: number, dt: number) {  // Four small steps of semi-implicit Euler: stable, and plenty precise for a few frames' worth.  const h = dt / 4;  for (let i = 0; i < 4; i += 1) {    velocity += (STIFFNESS * (target - position) - DAMPING * velocity) * h;    position += velocity * h;  }  return { position, velocity };}/** Dragged past an end, the cards give less and less: `overshoot` pages pulled, pages moved. */export function rubberBand(overshoot: number) {  return 1 - 1 / (overshoot * 0.55 + 1);
tips.tsx
import type { ReactNode, SVGProps } from 'react';/* * The sample cards: five tips for keeping a house plant happy, each with a small drawing. The * drawings fill with the theme's colour (--pager-accent) and draw in its ink (currentColor). */export type Tip = {  id: string;  /** A word above the title. */  label: string;  title: string;  text: string;  /** A 48 × 48 drawing. */  art: ReactNode;};function Art({ children, ...props }: SVGProps<SVGSVGElement>) {  return (    <svg      viewBox="0 0 48 48"      fill="none"      stroke="currentColor"      strokeWidth="2.2"
demo-cursor.tsx
'use client';import type { Ref } from 'react';import styles from './demo-cursor.module.css';/* * The demo's pointer: while the demo plays on its own, an arrow glides to what it is about to * press and dips as it presses, so the demo reads like someone using the component. It sits in * the stage (its offset parent). Each glide is a CSS transition, the two axes on different * easings so that the path bends a little: nothing runs between moves. */const ARROW = 'M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z';export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) {  return (    <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true">      <span className={styles.vertical}>        <span className={styles.ring} />        <svg className={styles.arrow} viewBox="0 0 20 20">          {/* Its shadow: the arrow again, offset and faint (a CSS filter would cost a large              GPU surface while the cursor moves). */}          <path className={styles.shade} d={ARROW} transform="translate(0.3 1.1)" />          <path className={styles.body} d={ARROW} />
demo-cursor.module.css
/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in the stage. The   outer box carries the horizontal move and the inner one the vertical move, eased differently,   which bends the path into a gentle arc. The arrow keeps the system pointer's look, dark edged   in white, in both schemes; the ring where it presses turns light in the dark. */.cursor {  --ring: rgb(0 0 0 / 0.14);  --x: 0px;  --y: 0px;  --move: 0ms;  position: absolute;  top: 0;  left: 0;  z-index: 10;  pointer-events: none;  opacity: 0;  transform: translateX(var(--x));  transition:    opacity 0.25s ease,    transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);}@supports (color: light-dark(#fff, #000)) {  .cursor {    --ring: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.18));
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The carousel is a labelled region of slides. Cards off the page are hidden from screen readers, and page changes are announced (“Tip 2 of 5: Water deeply, then wait”).
  • Previous and next are real buttons. At an end they stay focusable but are aria-disabled, and pushing one announces “This is the last tip.” The arrow keys (and Home, End) work while either button has focus.
  • Only horizontal drags go to the cards (touch-action: pan-y), so a vertical swipe still scrolls the page. A two-finger trackpad swipe turns one page without triggering the browser’s back gesture.
  • Under reduced motion the cards and dots go straight to the page and the colours change at once, with no demo. The yellow theme uses dark chevrons, for contrast.