Skip to main content

ns-ui

Badge Unread Tarnish

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.

Usage

registry/core/badge-unread-tarnish/demo.tsx
"use client";

import { useId, useState } from "react";
import { PatinaPip } from "./component";

const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;

// three realistic inbox sections at three real ages, so the demo shows all
// three tarnish stages resting side by side without anyone waiting around —
// plus a button that lands new mail on the section that's gone quietest,
// so the instant re-polish (ring -> solid fill, flare) is the one thing
// that actually moves.
export default function PatinaPipDemo() {
  const [now] = useState(() => Date.now());
  const [archive, setArchive] = useState({
    count: 6,
    newestTimestamp: now - 9 * 24 * HOUR,
  });

  const messagesId = useId();
  const updatesId = useId();
  const archiveId = useId();

  function landNewMail() {
    setArchive({ count: 15, newestTimestamp: Date.now() });
  }

  // Demo-only labeling: spells out the stage each pip is currently in, next
  // to the pip itself, so the tarnish concept reads without anyone having to
  // watch it happen or cross-reference the paragraph above. The component's
  // own accessible name already does this for screen readers (aria-label);
  // this is the same information made visible for sighted users at a glance.
  function stageCaption(rawAgeMs: number): string {
    const ageMs = Math.max(0, rawAgeMs);
    if (ageMs < MINUTE) return "fresh · just now";
    if (ageMs < HOUR) return `fresh · ${Math.round(ageMs / MINUTE)}m ago`;
    if (ageMs < DAY) return `fresh · ${Math.round(ageMs / HOUR)}h ago`;
    if (ageMs < WEEK) return `waning · ${Math.round(ageMs / DAY)}d ago`;
    return `dormant · ${Math.round(ageMs / WEEK)}w ago`;
  }

  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-sm">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / badge-unread-tarnish
        </p>
        <h1 className="text-lg font-semibold text-foreground">Inbox</h1>
        <p className="mt-1 text-sm leading-relaxed text-ns-muted">
          Same shape, three different ages: a solid pip is fresh, a thin ring
          is a week gone quiet — no need to wait and watch to tell them apart.
        </p>

        <div
          data-ns-patina-focus
          className="mt-5 divide-y divide-border rounded-md border border-border"
        >
          <nav aria-label="Mail sections" className="divide-y divide-border">
            <button
              type="button"
              aria-describedby={messagesId}
              className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors duration-150 hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent"
            >
              <span className="flex flex-col gap-0.5">
                <span>Messages</span>
                <span className="font-mono text-[10px] tracking-wide text-ns-muted">
                  {stageCaption(3 * MINUTE)}
                </span>
              </span>
              <PatinaPip id={messagesId} count={3} newestTimestamp={now - 3 * MINUTE} />
            </button>
            <button
              type="button"
              aria-describedby={updatesId}
              className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors duration-150 hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent"
            >
              <span className="flex flex-col gap-0.5">
                <span>Updates</span>
                <span className="font-mono text-[10px] tracking-wide text-ns-muted">
                  {stageCaption(30 * HOUR)}
                </span>
              </span>
              <PatinaPip id={updatesId} count={12} newestTimestamp={now - 30 * HOUR} />
            </button>
            <button
              type="button"
              aria-describedby={archiveId}
              className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left text-sm text-foreground transition-colors duration-150 hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ns-accent"
            >
              <span className="flex flex-col gap-0.5">
                <span>Archive</span>
                <span className="font-mono text-[10px] tracking-wide text-ns-muted">
                  {stageCaption(Date.now() - archive.newestTimestamp)}
                </span>
              </span>
              <PatinaPip
                id={archiveId}
                count={archive.count}
                newestTimestamp={archive.newestTimestamp}
              />
            </button>
          </nav>

          <div className="flex items-center justify-between gap-4 px-4 py-3">
            <span className="font-mono text-[11px] text-ns-muted">
              re-polish the archive row
            </span>
            <button
              type="button"
              data-ns-patina-land
              onClick={landNewMail}
              className="rounded-sm border border-border px-3 py-1.5 font-mono text-[11px] tracking-widest text-ns-muted transition-colors duration-200 hover:border-foreground/20 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
            >
              LAND NEW MAIL
            </button>
          </div>
        </div>

        <p className="mt-4 font-mono text-[11px] text-ns-muted">
          fill, ring weight and text tone move together — never color alone
        </p>
      </div>
    </main>
  );
}

Component

registry/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 --ns-muted ring with --ns-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;
  /** extra classes merged onto the rendered root element */
  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-ns-muted border border-ns-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>
    </>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/badge-unread-tarnish.tsx, so point the import there.

Use when 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.

Install

npx shadcn add https://design.helpmarq.com/r/badge-unread-tarnish.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

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 --ns-muted border with --ns-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, --ns-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).

Props

PropTypeDefaultDescription
countnumber—unread item count. Incidental to the component's point — see newestTimestamp.
newestTimestampnumber | Date—timestamp of the newest unread item; the sole driver of the tarnish stage.
id?string—id placed on the pip itself, e.g. so a nav item can aria-describedby it. Defaults to an internal useId.
className?string—extra classes merged onto the rendered root element