shadimage
knock-codes
componentexpiry-pillsource: ok · refreshed

Expiry pill

Shows a preview lifetime as fine, expiring soon, or expired. Does not verify codes, persist state, or grant or revoke access. Contract: the consumer owns lifecycle timestamps and action callbacks.

npx shadcn add "https://knock.codes/r/react/expiry-pill.json"
free

Where it goes

testimonialstats

Visual style

terminal-mono

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/8/2026, 1:43:37 AM

registry/components/expiry-pill.tsx
/**
 * MIT License — Copyright (c) 2026 Knock contributors.
 * Shows a preview lifetime as fine, expiring soon, or expired.
 * Does not verify codes, persist state, or grant or revoke access.
 * Contract: the consumer owns lifecycle timestamps and action callbacks.
 * @version 0.2.0
 * @minimalExample <ExpiryPill expiresAt="2030-01-01T12:00:00Z" />
 * @fullExample <ExpiryPill expiresAt="2030-01-01T12:00:00Z" soonThreshold={3600} theme="light" labels={{ fine: "Available", soon: "Ending soon", expired: "Expired", pending: "Expiry", unavailable: "Ask the owner", remaining: seconds => `${seconds}s` }} />
 * @lifecycle gate
 * @whenToUse A preview's remaining lifetime belongs next to the gate, or inside the bar that stays after unlock.
 * @notFor Enforcing the deadline. The pill reaches its expired state and changes nothing about what your application serves.
 * @pitfall Rendering the expired state hides nothing. Your own check decides whether the preview is still available.
 * @pitfall `soonThreshold` is in seconds, not milliseconds. The default window is one hour.
 * @a11y Native controls, visible focus, localized labels; lifecycle messages use polite announcements. No modal dialog is created.
 */
'use client';

import { useEffect, useState } from 'react';
export interface ExpiryPillProps {
  /** Expiry timestamp as ISO 8601 with timezone; required. */
  expiresAt: string;
  /** Duration considered expiring soon, in seconds. @default 86400 */
  soonThreshold?: number;
  /** Localized states and remaining-time formatter. */
  labels?: {
    /** Localized pending. */
    pending?: string;

    /** Localized fine. */
    fine?: string;

    /** Localized soon. */
    soon?: string;

    /** Localized expired. */
    expired?: string;

    /** Localized unavailable. */
    unavailable?: string;

    /** Localized remaining formatter. */
    remaining?: (seconds: number) => string;
  };

  /** Explicit theme; omitted follows the system preference. */
  theme?: 'light' | 'dark';
  /** Additional wrapper classes. */
  className?: string;
}
export function getExpiryState(
  expiresAt: string,
  now: number,
  soonThreshold = 86400,
): 'fine' | 'soon' | 'expired' | 'invalid' {
  const remaining = Date.parse(expiresAt) - now;
  return !Number.isFinite(remaining)
    ? 'invalid'
    : remaining <= 0
      ? 'expired'
      : remaining <= Math.max(0, soonThreshold) * 1000
        ? 'soon'
        : 'fine';
}
export function ExpiryPill({
  expiresAt,
  soonThreshold = 86400,
  labels,
  theme,
  className = '',
}: ExpiryPillProps) {
  const [now, setNow] = useState<number | null>(null);
  useEffect(() => {
    setNow(Date.now());
    const timer = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(timer);
  }, [expiresAt]);
  const state = now === null ? 'pending' : getExpiryState(expiresAt, now, soonThreshold);
  const seconds = now === null ? 0 : Math.max(0, Math.ceil((Date.parse(expiresAt) - now) / 1000));
  const remaining =
    state !== 'fine' && state !== 'soon'
      ? ''
      : (labels?.remaining?.(seconds) ??
        (seconds >= 86400
          ? `${Math.ceil(seconds / 86400)}d`
          : seconds >= 3600
            ? `${Math.ceil(seconds / 3600)}h`
            : `${Math.max(1, Math.ceil(seconds / 60))}m`));
  const text =
    state === 'pending'
      ? (labels?.pending ?? 'Preview expiry')
      : state === 'invalid'
        ? (labels?.unavailable ?? 'Expiry unavailable')
        : state === 'expired'
          ? (labels?.expired ?? 'Preview expired')
          : `${state === 'soon' ? (labels?.soon ?? 'Expiring soon') : (labels?.fine ?? 'Preview available')} · ${remaining}`;
  return (
    <span
      data-knock-item="expiry"
      data-theme={theme}
      data-state={state}
      className={`inline-flex max-w-full items-center gap-2 rounded-full border border-[var(--knock-border,var(--knock-line))] bg-[var(--knock-bg,var(--knock-surface))] px-3 py-1.5 text-xs ${className}`}
    >
      <style>{`[data-knock-item]{--knock-surface:#fffefa;--knock-text:#30382c;--knock-secondary:#65705b;--knock-line:#d8dece;--knock-highlight:#a3462d;color:var(--knock-ink,var(--knock-text));color-scheme:light}[data-knock-item][data-theme=dark]{--knock-surface:#293028;--knock-text:#f0f3e9;--knock-secondary:#b6c2ab;--knock-line:#59654f;--knock-highlight:#f1a187;color-scheme:dark}@media(prefers-color-scheme:dark){[data-knock-item]:not([data-theme=light]){--knock-surface:#293028;--knock-text:#f0f3e9;--knock-secondary:#b6c2ab;--knock-line:#59654f;--knock-highlight:#f1a187;color-scheme:dark}}[data-knock-item] a:focus-visible,[data-knock-item] button:focus-visible,[data-knock-item] [tabindex]:focus-visible{outline:2px solid var(--knock-accent,var(--knock-highlight));outline-offset:4px}[data-knock-item] button,[data-knock-item] a{overflow-wrap:anywhere}@media(prefers-reduced-motion:reduce){[data-knock-item] *{animation:none!important;transition:none!important}}`}</style>
      <svg
        aria-hidden="true"
        width="14"
        height="14"
        viewBox="0 0 24 24"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
      >
        <circle cx="12" cy="12" r="9" />
        <path d={state === 'expired' ? 'm8 8 8 8m0-8-8 8' : 'M12 6v6l4 2'} />
      </svg>
      <span>{text}</span>
      <span className="sr-only" role="status" aria-live="polite">
        {state === 'expired'
          ? (labels?.expired ?? 'Preview expired')
          : state === 'soon'
            ? (labels?.soon ?? 'Expiring soon')
            : ''}
      </span>
    </span>
  );
}

More from knock-codes

Browse all components in this library →