Unread badge that tarnishes like brass: solid when fresh, an outline within a day, a muted ring after a week — new activity instantly re-polishes it with a small flare.
npx shadcn add https://design.helpmarq.com /r/badge-unread-tarnish.jsonregistry/core/badge-unread-tarnish/component.tsx"use client";
import { useEffect, useId, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// PatinaPip — an unread badge that ages like brass instead of just counting.
// Its data-stage is derived from the newest item's timestamp, not the count:
// fresh (< 1 day) is a solid --foreground fill with background-token digits,
// waning (< 1 week) thins to a transparent fill with a 1.5px --foreground
// ring, dormant (>= 1 week) settles into a 1px --muted ring with --muted
// digits — fill, outline weight and text tone all move together so the three
// stages read apart under monochrome viewing, never by hue alone. Ordinary
// aging (the interval tick, or a prop simply reflecting more elapsed time)
// crossfades those properties over 400ms via a plain CSS transition; new
// activity is different in kind, not degree, so it's fast-pathed around that
// transition (a one-frame transitionDuration:0 clamp) straight to "fresh"
// and topped with a 200ms spring-eased scale(1 → 1.12 → 1) flare via the Web
// Animations API, so re-polish always reads as instantaneous, not eased.
// The pip itself is decorative (role="img", not a control) — the accessible
// name lives on it as an aria-label ("3 unread, newest 2 days ago") for a
// focusable nav item to pull in via aria-describedby, and a visually-hidden
// aria-live region separately announces new arrivals as they happen. Pure
// DOM + CSS, zero dependencies, no canvas.
// ---------------------------------------------------------------------------
export type PatinaPipStage = "fresh" | "waning" | "dormant";
export interface PatinaPipProps {
/** unread item count. Incidental to the component's point — see newestTimestamp. */
count: number;
/** timestamp of the newest unread item; the sole driver of the tarnish stage. */
newestTimestamp: number | Date;
/** id placed on the pip itself, e.g. so a nav item can aria-describedby it. Defaults to an internal useId. */
id?: string;
className?: string;
}
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
const WEEK_MS = 7 * DAY_MS;
// how often the pip re-checks its own age while mounted, so a badge left
// open on screen tarnishes on its own without any new prop ever arriving.
const TICK_MS = MINUTE_MS;
const FLARE_KEYFRAMES: Keyframe[] = [
{ transform: "scale(1)" },
{ transform: "scale(1.12)", offset: 0.5 },
{ transform: "scale(1)" },
];
const FLARE_OPTIONS: KeyframeAnimationOptions = {
duration: 200,
easing: "cubic-bezier(0.34, 1.56, 0.64, 1)", // spring-like overshoot, not linear/ease
};
function toMs(ts: number | Date): number {
return ts instanceof Date ? ts.getTime() : ts;
}
function computeStage(ageMs: number): PatinaPipStage {
if (ageMs < DAY_MS) return "fresh";
if (ageMs < WEEK_MS) return "waning";
return "dormant";
}
function formatAge(ageMs: number): string {
const ms = Math.max(0, ageMs);
if (ms < MINUTE_MS) return "just now";
if (ms < HOUR_MS) {
const m = Math.round(ms / MINUTE_MS);
return `${m} minute${m === 1 ? "" : "s"} ago`;
}
if (ms < DAY_MS) {
const h = Math.round(ms / HOUR_MS);
return `${h} hour${h === 1 ? "" : "s"} ago`;
}
if (ms < WEEK_MS) {
const d = Math.round(ms / DAY_MS);
return `${d} day${d === 1 ? "" : "s"} ago`;
}
const w = Math.round(ms / WEEK_MS);
return `${w} week${w === 1 ? "" : "s"} ago`;
}
function useReducedMotion() {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setReduced(mq.matches);
const onChange = () => setReduced(mq.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
return reduced;
}
const STAGE_CLASSES: Record<PatinaPipStage, string> = {
fresh: "bg-foreground text-background border border-foreground font-semibold",
waning: "bg-transparent text-foreground border-[1.5px] border-foreground font-medium",
dormant: "bg-transparent text-muted border border-muted font-normal",
};
const BASE_CLASSES =
"ns-badge-unread-tarnish inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full px-1.5 font-mono text-[11px] leading-none tabular-nums select-none transition-[background-color,border-color,color,border-width] duration-[400ms] ease-out";
export function PatinaPip({ count, newestTimestamp, id, className }: PatinaPipProps) {
const autoId = useId();
const pipId = id ?? autoId;
const nodeRef = useRef<HTMLSpanElement>(null);
const prevTsRef = useRef(toMs(newestTimestamp));
const mountedRef = useRef(false);
const reducedMotion = useReducedMotion();
// SSR-safe placeholder: never reads Date.now() during render (server and
// client's pre-effect render must match exactly), corrected to the real
// elapsed age the moment effects can run on the client.
const [ageMs, setAgeMs] = useState(0);
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
const ts = toMs(newestTimestamp);
if (!mountedRef.current) {
// first client-side pass after hydration: adopt the real elapsed time
// silently, no crossfade suppression and no flare — this is settling
// in, not new activity.
mountedRef.current = true;
prevTsRef.current = ts;
setAgeMs(Math.max(0, Date.now() - ts));
return;
}
const prev = prevTsRef.current;
prevTsRef.current = ts;
const isNewer = ts > prev;
const nextAge = Math.max(0, Date.now() - ts);
if (isNewer) {
const node = nodeRef.current;
// clamp the transition to instant for exactly this update, so the
// reset to "fresh" reads as a snap, not a 400ms crossfade — then
// restore normal crossfade timing for whatever ages naturally next.
if (node) node.style.transitionDuration = "0s";
setAgeMs(nextAge);
setAnnouncement(`New unread activity, ${count} unread`);
requestAnimationFrame(() => {
if (node) node.style.transitionDuration = "";
if (node && !reducedMotion) node.animate(FLARE_KEYFRAMES, FLARE_OPTIONS);
});
} else {
setAgeMs(nextAge);
}
}, [newestTimestamp, count, reducedMotion]);
useEffect(() => {
const tick = window.setInterval(() => {
setAgeMs(Math.max(0, Date.now() - prevTsRef.current));
}, TICK_MS);
return () => window.clearInterval(tick);
}, []);
if (count <= 0) return null;
const stage = computeStage(ageMs);
const label = `${count} unread, newest ${formatAge(ageMs)}`;
return (
<>
<span
ref={nodeRef}
id={pipId}
role="img"
aria-label={label}
data-stage={stage}
className={[BASE_CLASSES, STAGE_CLASSES[stage], className].filter(Boolean).join(" ")}
>
<span aria-hidden="true">{count}</span>
</span>
{/* async re-polish announcements — the resting description above already
covers "how stale", this only ever speaks up when new mail lands */}
<span aria-live="polite" className="sr-only">
{announcement}
</span>
</>
);
}
an unread pip that encodes staleness, not just quantity — fill drops to an outline within a day and settles into a muted ring after a week, so a count from a minute ago and the same count from a month ago look different at rest with zero motion required; pick this over a plain count badge whenever a nav item's age matters as much as its total.
An unread-count badge for nav items and sidebars whose whole point is a second dimension alongside the count: how stale the newest item is, readable at rest with zero motion. It's a single rounded-full <span role="img"> (not a control — the nav item it decorates is the focusable element) whose data-stage is derived purely from a newestTimestamp prop, never from the count: fresh (age under 24h) renders a solid --foreground fill with background-token digits and semibold weight; waning (under 7 days) drops the fill to transparent and thins to a 1.5px --foreground border at medium weight; dormant (7 days or more) settles to a 1px --muted border with --muted digits at normal weight — fill, border width and text tone all move together so the three stages differ by more than hue and survive monochrome viewing. Ordinary aging (a re-check timer, or a prop simply reflecting more elapsed time) crossfades those CSS properties over a plain 400ms transition; new activity is categorically different, so it's fast-pathed around that transition with a one-frame transitionDuration:0 clamp straight to the fresh stage, topped with a 200ms spring-eased (cubic-bezier back-out) scale(1 to 1.12 back to 1) flare via the Web Animations API, so re-polish always reads as an instant snap rather than an eased fade. Accessibility: the pip's own aria-label spells out both dimensions as text ("3 unread, newest 2 days ago"), meant to be pulled in by the decorated nav item's aria-describedby pointing at the pip's id (falls back to an internal useId if none is passed); a separate visually-hidden aria-live="polite" region announces only the re-polish moment itself ("New unread activity, N unread"), kept apart from the resting description so routine renders never spam a live region. A count of zero or less renders nothing, matching how unread badges actually get used. Zero dependencies, no canvas — DOM and CSS only, colors entirely from --background, --foreground, --muted and --border. Rendering never touches Date.now() during the initial render (server and the pre-effect client render both compute an age of zero), so there is no hydration mismatch; the real elapsed age is adopted the moment effects can run, and a per-minute interval keeps a badge left open on screen decaying through its stages on its own. prefers-reduced-motion drops the spring flare (the instant stage snap and the 400ms crossfade both stay, since neither is extra motion).