DHxWhy

Expiring Badge – Status Badge with a Time Rim that Empties and Morphs to Expired

Component

About

Expiring Badge for React and Tailwind CSS (shadcn/ui): a status badge whose rim shows the share of time left and empties as time passes, turns amber, then red, and morphs its label to Expired. It is built to show how long a trial, offer or code has left. Pass expiresAt with startedAt or duration; the label can be a function of the time left, live mode rolls its digits each second, onExpire fires once, now() is injectable. Focusable with a tooltip, polite expiry announcement, reduced motion, light and dark. Use it for trials, sales, New labels, codes and sessions. The included demo shows five badges on an account card and makes no requests. Requires React only, with no animation library.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Expiring Badge – Status Badge with a Time Rim that Empties and Morphs to Expired to your project and fits it to your theme.

Code

How Expiring Badge – Status Badge with a Time Rim that Empties and Morphs to Expired is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { FlaskConical, KeyRound, Sparkles, Tag } from "lucide-react";
import { ExpiringBadge, formatRemaining } from "@/components/ui/expiring-badge";
import type { ExpiringBadgeRemaining } from "@/components/ui/expiring-badge";

const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

const BASE = new Date(2026, 10, 8, 10, 16, 20).getTime();
const TRIAL_END = new Date(2026, 10, 11, 10, 30).getTime();
const TRIAL_START = TRIAL_END - 7 * DAY;
const SALE_END = BASE + 2 * HOUR + 13 * MINUTE + 40 * SECOND;
const SALE_START = SALE_END - 8 * HOUR;
const NEW_START = BASE - 2 * DAY;
const NEW_END = NEW_START + 7 * DAY;
const CODE_WINDOW = 6 * SECOND;
const CYCLE = 10 * SECOND;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";

const pad = (value: number) => String(value).padStart(2, "0");
const saleLabel = ({ hours, minutes, seconds }: ExpiringBadgeRemaining) => `Sale ends ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
const trialLabel = (remaining: ExpiringBadgeRemaining) => `Trial · ${formatRemaining(remaining)}`;
const codeLabel = ({ ms }: ExpiringBadgeRemaining) => `Expires in ${Math.ceil(ms / SECOND)}s`;

export default function Demo({ autoplay = true }: { autoplay?: boolean }) {
  const params = React.useMemo(() => new URLSearchParams(typeof window === "undefined" ? "" : window.location.search), []);
  const fixed = params.get("clock") === "fixed";
  const offset = Number(params.get("at") ?? 0) || 0;
  const speed = Math.max(1, Number(params.get("speed") ?? 1) || 1);
  const durationOnly = params.get("scene") === "duration";
  const loaded = React.useRef(0);
  if (loaded.current === 0 && typeof performance !== "undefined") loaded.current = performance.now();
  const now = React.useMemo(() => (fixed ? () => BASE + offset : () => BASE + offset + (performance.now() - loaded.current)), [fixed, offset]);

  const [cycle, setCycle] = React.useState(0);
  const [stopped, setStopped] = React.useState(false);
  const [expiries, setExpiries] = React.useState(0);
  const [entered, setEntered] = React.useState(false);
  const [motion, setMotion] = React.useState({ ready: false, reduced: true });
  const stageRef = React.useRef(null as HTMLElement | null);
  const scripted = autoplay && !fixed && !stopped;

  React.useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setMotion({ ready: true, reduced: media.matches });
    update();
    media.addEventListener("change", update);
    return () => media.removeEventListener("change", update);
  }, []);

  React.useEffect(() => {
    if (!motion.ready) return;
    const node = stageRef.current;
    if (autoplay && !motion.reduced && node && typeof node.animate === "function") {
      node.animate([{ opacity: 0, transform: "translateY(10px)" }, { opacity: 1, transform: "translateY(0)" }], { duration: 420, easing: EASE });
    }
    setEntered(true);
  }, [motion.ready, motion.reduced, autoplay]);

  React.useEffect(() => {
    if (!scripted) return;
    let timer = 0;
    const schedule = (next: number) => {
      timer = window.setTimeout(() => { setCycle(next); schedule(next + 1); }, Math.max(0, loaded.current + next * CYCLE - performance.now()));
    };
    schedule(cycle + 1);
    return () => window.clearTimeout(timer);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [scripted]);

  const stop = () => { if (scripted) setStopped(true); };
  const codeStart = BASE + cycle * CYCLE;
  const codeTiming = durationOnly ? { duration: CODE_WINDOW * speed } : { startedAt: codeStart, duration: CODE_WINDOW * speed };

  return (
    <main
      data-demo-root
      data-cycle={cycle}
      data-stopped={stopped ? "true" : "false"}
      data-expiries={expiries}
      onPointerDownCapture={stop}
      onKeyDownCapture={stop}
      className="flex w-full items-center justify-center overflow-hidden bg-background px-6 text-foreground"
      style={{ height: "min(100svh, 760px)" }}
    >
      <section
        ref={stageRef}
        data-stage
        aria-labelledby="expiring-badge-demo-title"
        className="w-full max-w-[480px] rounded-2xl bg-card p-5 text-card-foreground shadow-sm ring-1 ring-border"
        style={{ opacity: entered ? 1 : 0 }}
      >
        <header className="flex items-start justify-between gap-4 px-1">
          <div className="min-w-0">
            <h2 id="expiring-badge-demo-title" className="text-sm font-semibold leading-5">Account overview</h2>
            <p className="text-xs leading-4 text-muted-foreground">Fieldstone workspace · plan, offers and access</p>
          </div>
          <span className="shrink-0 text-xs leading-5 text-muted-foreground tabular-nums">Nov 8</span>
        </header>
        <ul className="mt-3 divide-y divide-border" aria-label="Status badges">
          <li data-row="trial" className="flex items-center justify-between gap-4 py-3 pl-1 pr-1">
            <ExpiringBadge name="Trial" expiresAt={TRIAL_END} startedAt={TRIAL_START} label={trialLabel} tone="chart-1" now={now} enterDelay={0} />
            <span className="truncate text-sm text-muted-foreground">Pro plan trial</span>
          </li>
          <li data-row="sale" className="flex items-center justify-between gap-4 py-3 pl-1 pr-1">
            <ExpiringBadge name="Flash sale" expiresAt={SALE_END} startedAt={SALE_START} label={saleLabel} live tone="chart-2" icon={<Tag />} now={now} enterDelay={60} />
            <span className="truncate text-sm text-muted-foreground">Flash sale · annual plans</span>
          </li>
          <li data-row="new" className="flex items-center justify-between gap-4 py-3 pl-1 pr-1">
            <ExpiringBadge name="New" expiresAt={NEW_END} startedAt={NEW_START} label="New" tone="chart-1" icon={<Sparkles />} now={now} enterDelay={120} />
            <span className="truncate text-sm text-muted-foreground">Shared dashboards</span>
          </li>
          <li data-row="beta" className="flex items-center justify-between gap-4 py-3 pl-1 pr-1">
            <ExpiringBadge name="Beta" label="Beta" tone="muted" icon={<FlaskConical />} now={now} enterDelay={180} />
            <span className="truncate text-sm text-muted-foreground">AI summaries</span>
          </li>
          <li data-row="code" className="flex items-center justify-between gap-4 py-3 pl-1 pr-1">
            <ExpiringBadge
              key={cycle}
              name="Sign-in code"
              label={codeLabel}
              live
              tone="chart-2"
              icon={<KeyRound />}
              now={now}
              speed={speed}
              enterDelay={240}
              onExpire={() => setExpiries((count) => count + 1)}
              {...codeTiming}
            />
            <span className="truncate text-sm text-muted-foreground tabular-nums">Sign-in code · 482 913</span>
          </li>
        </ul>
      </section>
    </main>
  );
}

Dependencies

More by DHxWhy

View all
  • Product Rail Gallery – Fashion Store Hero Section

    Product Rail Gallery – Fashion Store Hero Section

    2.2K
  • Sushi Conveyor Belt Menu – Restaurant Hero Section

    Sushi Conveyor Belt Menu – Restaurant Hero Section

    883
  • Team Lanyard Rail – Meet the Team Section with Flip Badges

    Team Lanyard Rail – Meet the Team Section with Flip Badges

    830
  • Skateboard Deck Rail Gallery – Skate Shop Hero Section

    Skateboard Deck Rail Gallery – Skate Shop Hero Section

    431
  • Sun Moon Dark Mode Theme Toggle

    Sun Moon Dark Mode Theme Toggle

    328
  • Picture Rail Image Gallery – Art Print Shop Hero Section

    Picture Rail Image Gallery – Art Print Shop Hero Section

    268
  • Pendant Lamp Rail Gallery – Lighting Store Hero Section

    Pendant Lamp Rail Gallery – Lighting Store Hero Section

    243
  • World Testimonials Globe – Social Proof Hero Section

    World Testimonials Globe – Social Proof Hero Section

    217