Light
Find the bright spot
Most leafy plants love bright light, just out of the direct midday sun.
Choose your reading theme.
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.tsxLight
Find the bright spot
Most leafy plants love bright light, just out of the direct midday sun.
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
When not to use
You can add this component with the shadcn CLI or by hand:
Into components/pager-pill of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/pager-pill.jsonCopy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
As shown under Usage, below.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
tips | Tip[] | TIPS | The 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. |
defaultPage | number | — | 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. |
autoplay | boolean | true | Pages 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. |
className | string | — | 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.
// 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 (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;/* * 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);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"'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} />/* 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 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;