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>
);
}








