Streak badges
Milestone badges for a daily streak that tilt towards the pointer, and a sheet that celebrates each new one before sending it to the shelf.
streak-badges/streak-badges.tsx5-day streak
2 days to the 7-day badge
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 daily habit worth rewarding: practice, a check-in, a lesson a day.
- A few milestones spaced out over weeks, each worth a small celebration when it lands.
- Showing at a glance what is earned, what is next and how many days are left.
- A profile or a home screen where the shelf can sit and the sheet can rise over it.
When not to use
- Progress with no daily rhythm, or measured in units rather than days. Use a progress bar or a meter.
- Dozens of achievements in many categories. Use a grid or a list of achievements with filters.
- Work tools where a celebration would get in the way. A quiet count in the header is enough.
- A one-off success. Use a toast or a confirmation instead of a badge.
You can add this component with the shadcn CLI or by hand:
Into components/streak-badges of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/streak-badges.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
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.
Usage
import { StreakBadges } from './streak-badges';// The badges, their emblems, colours and words are in milestones.ts.export function HabitStreak({ days, save }: { days: number; save: (days: number) => void }) { return <StreakBadges defaultDays={days} onCheckIn={save} 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.
A shelf of seven badges for a daily streak, each a raised hexagon with a colour and an emblem of its own, drawn in SVG. Hover one and it tilts towards the pointer as a sheen slides across it. Check in on the day that reaches a milestone and a sheet rises: the badge pops in with a burst of sparkles, its number rolls up, and OK sends it flying to its place on the shelf.
- Every badge is one SVG built from the same layers: a darker rim and a lighter face (two rounded hexagons lit from the top), a raised emblem with a shadow under it and a light edge along its top, the number embossed the same way, and a soft shadow on the ground. Only the colour, worked out from a hue, and the emblem change from badge to badge; locked badges are drawn in greys under a frost of white specks.
- The badge under the pointer tilts towards it: each move sets a perspective rotation on that badge alone, and a band of light clipped to the hexagon slides across its face. When the pointer leaves, the badge springs back flat and loses its 3D transform, so only one badge is ever a 3D layer.
- The check-in that reaches a milestone raises a bottom sheet with a tinted header. The badge pops in on a spring with a little turn and sparkles fly out and fade; each digit then rolls up behind a window from 0 to its value, and the title and line come in after.
- OK closes the sheet, and the badge flies to its slot along an arc, turning and shrinking from the sheet’s box to the slot’s, and the slot takes it with a bounce. Both boxes are measured on screen and divided by the stage’s scale, so the flight lands in a scaled-down card too.
- Everything moves with transforms and opacity through the Web Animations API, and nothing runs at rest. The sheet’s moving parts sit under a plain rectangular clip and its veil fades its colour, because rounded clips and stage-sized fading layers cost a phone memory.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; the badges are medal art and keep their colours. At night the sheet’s header takes deep tones of its badge’s hue, set inline next to the day tones.
| Prop | Type | Default | Description |
|---|---|---|---|
milestones | readonly Milestone[] | MILESTONES | The badges, in order of days: each with its days, name, emblem, hue and saturation, and the title and line of its sheet. |
defaultDays | number | 5 | The streak at first, in days; the badges up to it start earned. |
defaultOpen | boolean | false | Opens with the sheet of the badge defaultDays reaches raised (when it is a milestone’s days), as if just earned. Turns the demo off. |
onCheckIn | (days: number) => void | — | Called with the streak after each check-in. |
onEarn | (milestone: Milestone) => void | — | Called with the milestone a check-in reaches, as its sheet rises. |
autoplay | boolean | true | While on screen and untouched, a demo pointer checks in until a badge is earned, closes its sheet and tilts a badge or two. |
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 CSSProperties, type PointerEvent,} from 'react';import { Badge, paletteOf, rollUp } from './badge';import { GhostPointer, runDemo } from './ghost-pointer';import { MILESTONES, type Milestone } from './milestones';import { easingOf, spring } from './spring';import { useAutoplay } from './use-autoplay';import styles from './streak-badges.module.css';/* * Streak badges: a shelf of milestone badges for a daily streak, and the moment a new one is * earned. */* Streak badges (streak-badges.tsx, badge.tsx). Sizes are in em: the stage sets the font-size from its own width (a container query), so the card grows with the room it has. --spring and --rise, the easings of the springy transitions, come from the component. Colours are the tokens below, each light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The badges are medal art (badge.tsx): their colours are the same in both. The sheet's colours that follow its badge (--tint, --glow, --deep, --spark and their night tones) come from the component, set on the sheet. */.root { /* The stage and its dots; the card on it; the sheet, a step lighter again at night. */ --surface: #e8e8eb; --dots: rgb(0 0 0 / 0.05); --surface-2: #fff; --sheet-bg: #fff; /* The words: titles and figures; the lines under them; a locked badge's name (the same on both). */ --ink: #1c1c1e; --ink-muted: #6e6e73; --ink-faint: #8e8e93; /* Hairlines: the stage's edge, the card's, the sheet's; the rule over the shelf; the track of the next badge's bar. */ --line: rgb(0 0 0 / 0.06);import { useId, type ReactNode } from 'react';import type { Emblem, Milestone } from './milestones';/* * A streak badge, drawn in SVG so it stays crisp at any size. Every badge is made of the same * layers, from the back: * * - a soft shadow on the ground, and the edge: a darker copy of the hexagon just below it; * - the rim, a rounded hexagon, and the face, a smaller and lighter one, both lit from the top; * - the emblem, raised: a darker copy below it, its fill, a light line along its top; * - the number of days, embossed the same way, and "days" under it; * - the sheen, a band of light, hidden until the badge tilts (the stylesheet shows it). * * Only the colours and the emblem change from one badge to the next. A locked badge is drawn in * greys under a frost of white specks, its number still readable. * * The number is set digit by digit, each in a slot of the same width, so that a digit can roll: * a `rolling` badge draws each digit as a strip of digits behind a window, and `rollUp` slides * the strips up. */export type Palette = { rim: readonly [string, string]; edge: string;/* * The streak's milestones: the badges on the shelf, in order of days. Each one has an emblem and * a colour of its own, and the words its sheet shows when it is earned. *//** The raised shape behind a badge's number. */export type Emblem = 'leaf' | 'ribbon' | 'flower' | 'flame' | 'star' | 'crown' | 'gem';export type Milestone = { /** The streak, in days, that earns it. */ days: number; /** Its name, under it on the shelf. */ name: string; emblem: Emblem; /** Its colour, drawn in pastel tones: a hue (0 to 360) and a saturation (0 to 100). */ hue: number; saturation: number; /** The heading of its sheet, and the line under it. */ title: string; line: string;};export const MILESTONES: readonly Milestone[] = [ {/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = { /** The curve, as a CSS `linear()` easing. */ easing: string; /** How long it takes to settle, in ms: the duration to play the curve over. */ duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring { // The damping ratio (1: no overshoot) and the natural frequency, in radians per second. const damping = 1 - bounce;'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. */export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */ press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class Stopped extends Error {}'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 shelf is a list of images named by their state: “7-day streak, earned”, “14-day streak, locked, 4 days to go”.
- Check in today and Reset are buttons. A check-in is announced (“Checked in: 6-day streak. 1 day to the next badge.”), and so is a new badge; the demo stays silent.
- The sheet is a modal dialog named by its title and described by its line: focus moves to OK as it opens, Escape or OK closes it, focus returns to Check in today, and the card behind it is inert.
- Under reduced motion nothing tilts, rolls, bursts or flies, the sheet appears at once, and the demo does not play.