Skip to content

Resolution & Rules Card

A status timeline, rules and a dispute countdown.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/resolution-card.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/markets/format.ts
    import { FULL } from "@/lib/markets/math";
    
    const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
    // minimumFractionDigits: 0 matters: currency defaults to 2, and Node and browsers clamp it differently ("$412.0K" vs "$412K").
    const usdCompact = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: "compact", minimumFractionDigits: 0, maximumFractionDigits: 1 });
    
    /** "64%" (or "64.2%" with decimals). */
    export const formatProbability = (bps: number, decimals = 0) => `${(bps / 100).toFixed(decimals)}%`;
    
    /** "64¢". */
    export const formatPrice = (bps: number) => `${Math.round(bps / 100)}¢`;
    
    /** "$1,234.56", or "$1.2M" compact. Input in cents. */
    export const formatUsd = (cents: number, compact = false) => (compact ? usdCompact.format(cents / 100) : usd.format(cents / 100));
    
    /** Shares from hundredths: "156.25". */
    export const formatShares = (hundredths: number) => (hundredths / 100).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
    
    /** "▲ 4%" / "▼ 2.5%" from a basis-point change (never color alone: the arrow carries the direction). */
    export function formatChange(bps: number) {
      const value = Math.abs(bps) / 100;
      return `${bps >= 0 ? "▲" : "▼"} ${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)}%`;
    }
    
    /** Signed percent from basis points: "+56%". */
    export const formatReturn = (bps: number) => `${bps >= 0 ? "+" : "−"}${(Math.abs(bps) / 100).toFixed(bps % 100 === 0 ? 0 : 1)}%`;
    
    /** "Ends in 3d 4h", "Ends in 42m" or "Ended". */
    export function timeLeft(endsAt: number, now: number) {
      const ms = endsAt - now;
      if (ms <= 0) return "Ended";
      const minutes = Math.floor(ms / 60000);
      const days = Math.floor(minutes / 1440);
      const hours = Math.floor((minutes % 1440) / 60);
      if (days > 0) return `Ends in ${days}d ${hours}h`;
      if (hours > 0) return `Ends in ${hours}h ${minutes % 60}m`;
      return `Ends in ${Math.max(1, minutes)}m`;
    }
    
    /** A fixed, locale-stable date: "Mar 14, 2027". */
    export const formatDate = (ms: number) => new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }).format(ms);
    
    export { FULL };
    lib/markets/math.ts
    /**
     * Prediction market math on integers only: prices in basis points (10000 = $1.00 = 100%), money in
     * cents and shares in hundredths. A share pays $1 (100 cents) if its outcome wins.
     */
    export const FULL = 10000;
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    
    /** The other side of a binary price: Yes + No = 100¢. */
    export const complement = (price: number) => FULL - price;
    
    /** Shares (in hundredths) that `amount` cents buys at `price` basis points. */
    export const sharesFor = (amount: number, price: number) => (price > 0 ? Math.floor((amount * FULL) / price) : 0);
    
    /** Payout in cents if those shares win: each share pays 100 cents, so hundredths of a share map 1:1 to cents. */
    export const payoutFor = (shares: number) => shares;
    
    /** Value in cents of shares (hundredths) at a price. */
    export const valueOf = (shares: number, price: number) => Math.floor((shares * price) / FULL);
    
    export interface BuyQuote {
      /** Average fill price, basis points. */
      averagePrice: number;
      /** Price after the trade, basis points. */
      newPrice: number;
      /** Price impact, basis points (newPrice − price). */
      impact: number;
      /** Shares received, hundredths. */
      shares: number;
      /** Payout if it wins, cents. */
      payout: number;
      /** Return if it wins, basis points of the amount (5600 = +56%). */
      returnBps: number;
    }
    
    /**
     * A simulated buy against a constant-product style curve: the more of the pool you take, the further
     * the price moves toward $1. Good enough to show impact and average price in a demo.
     */
    export function quoteBuy(price: number, amount: number, liquidity: number): BuyQuote {
      const p = clamp(Math.round(price), 1, FULL - 1);
      if (amount <= 0) return { averagePrice: p, newPrice: p, impact: 0, shares: 0, payout: 0, returnBps: 0 };
      const take = Math.floor((amount * FULL) / (amount + Math.max(1, liquidity)));
      const newPrice = clamp(p + Math.floor(((FULL - p) * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      const shares = sharesFor(amount, averagePrice);
      const payout = payoutFor(shares);
      return { averagePrice, newPrice, impact: newPrice - p, shares, payout, returnBps: Math.floor(((payout - amount) * FULL) / amount) };
    }
    
    /** A simulated limit buy: fills entirely at the limit price, no impact. */
    export function quoteLimit(limitPrice: number, amount: number): BuyQuote {
      const p = clamp(Math.round(limitPrice), 1, FULL - 1);
      const shares = sharesFor(Math.max(0, amount), p);
      return { averagePrice: p, newPrice: p, impact: 0, shares, payout: shares, returnBps: amount > 0 ? Math.floor(((shares - amount) * FULL) / amount) : 0 };
    }
    
    /** Proceeds in cents from selling shares (hundredths), with the price sliding toward 0 by the same curve. */
    export function quoteSell(price: number, shares: number, liquidity: number) {
      const p = clamp(Math.round(price), 1, FULL - 1);
      const gross = valueOf(shares, p);
      const take = Math.floor((gross * FULL) / (gross + Math.max(1, liquidity)));
      const newPrice = clamp(p - Math.floor((p * take) / FULL), 1, FULL - 1);
      const averagePrice = Math.floor((p + newPrice) / 2);
      return { proceeds: valueOf(shares, averagePrice), averagePrice, newPrice, impact: newPrice - p };
    }
    
    /** Profit and loss in cents for shares bought at an average price, marked at the current price. */
    export function profitAndLoss(shares: number, averagePrice: number, currentPrice: number) {
      const cost = valueOf(shares, averagePrice);
      const value = valueOf(shares, currentPrice);
      return { cost, value, pnl: value - cost, pnlBps: cost ? Math.floor(((value - cost) * FULL) / cost) : 0 };
    }
    
    /** Scales positive weights so they sum to exactly 10000 (rounding error goes to the largest). */
    export function normalize(prices: number[]) {
      const total = prices.reduce((sum, p) => sum + Math.max(0, p), 0) || 1;
      const scaled = prices.map((p) => Math.floor((Math.max(0, p) * FULL) / total));
      const rest = FULL - scaled.reduce((sum, p) => sum + p, 0);
      const largest = scaled.indexOf(Math.max(...scaled));
      if (largest >= 0) scaled[largest] += rest;
      return scaled;
    }
    lib/markets/types.ts
    /** Prices and probabilities are integer basis points (0–10000: 6400 = 64% = 64¢), money is integer cents. */
    export type MarketStatus = "open" | "closing" | "resolved";
    
    export interface MarketOutcome {
      id: string;
      name: string;
      /** Price in basis points (0–10000). Binary markets: Yes + No always sum to 10000. */
      price: number;
      /** 24h change in basis points (positive = up). */
      change24h?: number;
      /** Avatar or icon image for the outcome. */
      image?: string;
      /** Volume traded on this outcome, in cents. */
      volume?: number;
    }
    
    export interface PricePoint {
      /** Timestamp, ms. */
      t: number;
      /** Price per outcome, in basis points, same order as outcomes. */
      prices: number[];
    }
    
    export interface Market {
      id: string;
      question: string;
      /** Topic label, e.g. "Tech". */
      topic: string;
      /** Market image URL; a generated icon is used without one. */
      image?: string;
      type: "binary" | "multi";
      /** Binary markets: [Yes, No]. Multi-outcome: probabilities normalized to 10000. */
      outcomes: MarketOutcome[];
      /** Total volume, cents. */
      volume: number;
      /** Simulated liquidity used for price impact, cents. */
      liquidity: number;
      traders: number;
      /** Names of a few traders, for the avatar group. */
      traderNames?: string[];
      /** End time, ms since epoch. */
      endsAt: number;
      status: MarketStatus;
      /** Winning outcome once resolved. */
      resolvedOutcomeId?: string;
      comments?: number;
      history?: PricePoint[];
      rules?: string;
      resolutionSource?: { label: string; href: string };
    }
    
    export type TradeSide = "buy" | "sell";
    lib/markets/use-now.ts
    import { useSyncExternalStore } from "react";
    
    let now = 0;
    const listeners = new Set<() => void>();
    let timer = 0;
    
    function subscribe(listener: () => void) {
      listeners.add(listener);
      // Refresh on every subscribe, so a component that mounts while the timer is already running
      // does not start with a timestamp up to 15 s old.
      now = Date.now();
      if (!timer) {
        timer = window.setInterval(() => {
          now = Date.now();
          listeners.forEach((l) => l());
        }, 15000);
      }
      return () => {
        listeners.delete(listener);
        if (!listeners.size) {
          window.clearInterval(timer);
          timer = 0;
        }
      };
    }
    
    /**
     * The current time, refreshed every 15 s. It is 0 on the server and during hydration, so countdowns
     * render a placeholder first and never mismatch.
     */
    export function useNow() {
      return useSyncExternalStore(
        subscribe,
        () => now || Date.now(),
        () => 0,
      );
    }
    
    const noop = () => () => {};
    
    /**
     * False on the server and during hydration, true after. Gate text derived from timestamps (axis
     * labels, dates) with it when the data may be generated relative to page load.
     */
    export function useHydrated() {
      return useSyncExternalStore(
        noop,
        () => true,
        () => false,
      );
    }
  3. Add the theme tokens

    This component uses --success and --success-foreground, which shadcn does not define by default. Add them to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
      --success-foreground: #ffffff;
    }
    
    .dark {
      --success: #4ade80;
      --success-foreground: #052e16;
    }
    
    @theme inline {
      --color-success: var(--success);
      --color-success-foreground: var(--success-foreground);
    }
  4. Copy the source

    components/ui/resolution-card/resolution-card.tsx
    "use client";
    
    import { useEffect, useState } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { ChevronDown, ExternalLink } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { RollingDigits } from "@/components/ui/rolling-digits/rolling-digits";
    import { formatDate } from "@/lib/markets/format";
    import type { Market } from "@/lib/markets/types";
    import { useNow } from "@/lib/markets/use-now";
    
    export type ResolutionStage = "open" | "closed" | "resolving" | "resolved";
    
    export interface ResolutionCardProps {
      /** The market (rules, resolution source, end time and the winning outcome once resolved). */
      market: Market;
      /** Where the market is in its lifecycle. @default "open" */
      stage?: ResolutionStage;
      /** When the dispute window closes, ms since epoch (shown while resolving). */
      disputeEndsAt?: number;
      /** Expected resolution time, ms since epoch. @default the end time plus a day */
      resolvesAt?: number;
      /** Classes for the card. */
      className?: string;
    }
    
    const STEPS: { id: ResolutionStage; label: string }[] = [
      { id: "open", label: "Open" },
      { id: "closed", label: "Trading closed" },
      { id: "resolving", label: "Resolving" },
      { id: "resolved", label: "Resolved" },
    ];
    
    /**
     * Status timeline (Open → Trading closed → Resolving → Resolved), expandable rules, the resolution
     * source and dates, a dispute countdown, and an outcome stamp with a glowing border on resolve.
     */
    export function ResolutionCard({ market, stage = "open", disputeEndsAt, resolvesAt, className }: ResolutionCardProps) {
      const reduceMotion = useReducedMotion();
      const now = useNow();
      const [rulesOpen, setRulesOpen] = useState(false);
      const [glow, setGlow] = useState(false);
      const current = STEPS.findIndex((s) => s.id === stage);
      const winner = market.outcomes.find((o) => o.id === market.resolvedOutcomeId);
    
      // A brief glow when the card reaches "resolved" (state set from timers, not during render).
      useEffect(() => {
        if (stage !== "resolved" || reduceMotion) return;
        const on = window.setTimeout(() => setGlow(true), 0);
        const off = window.setTimeout(() => setGlow(false), 1600);
        return () => {
          window.clearTimeout(on);
          window.clearTimeout(off);
        };
      }, [stage, reduceMotion]);
    
      const remaining = disputeEndsAt && now ? Math.max(0, disputeEndsAt - now) : 0;
      const days = Math.floor(remaining / 86400_000);
      const hours = Math.floor((remaining % 86400_000) / 3600_000);
      const minutes = Math.floor((remaining % 3600_000) / 60_000);
    
      return (
        <section aria-label="Resolution and rules" className={cn("relative w-full rounded-2xl border bg-background p-4 transition-[border-color,box-shadow] duration-700", glow ? "border-success shadow-[0_0_0_4px_color-mix(in_oklab,var(--success)_20%,transparent)]" : "border-border", className)}>
          <p className="text-sm font-medium text-foreground">Resolution</p>
          <ol className="mt-3 grid grid-cols-4 gap-1">
            {STEPS.map((step, i) => {
              const done = i < current || (stage === "resolved" && i === current);
              const active = i === current && stage !== "resolved";
              return (
                <li key={step.id} aria-current={i === current ? "step" : undefined} className="flex flex-col items-center gap-1.5 text-center">
                  <span className="relative flex w-full items-center">
                    <span className={cn("h-0.5 flex-1", i === 0 ? "opacity-0" : i <= current ? "bg-success" : "bg-border")} />
                    <span className={cn("relative grid size-6 shrink-0 place-items-center rounded-full border-2", done ? "border-success bg-success text-success-foreground" : active ? "border-primary bg-background" : "border-border bg-background")}>
                      {active ? <span className="absolute inset-0 animate-ping rounded-full border-2 border-primary opacity-60 motion-reduce:animate-none" /> : null}
                      {done ? (
                        <svg viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                          <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduceMotion ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.4, delay: i * 0.08 }} />
                        </svg>
                      ) : (
                        <span className={cn("size-1.5 rounded-full", active ? "bg-primary" : "bg-border")} />
                      )}
                    </span>
                    <span className={cn("h-0.5 flex-1", i === STEPS.length - 1 ? "opacity-0" : i < current ? "bg-success" : "bg-border")} />
                  </span>
                  <span className={cn("text-[11px] leading-tight", i <= current ? "font-medium text-foreground" : "text-muted-foreground")}>{step.label}</span>
                </li>
              );
            })}
          </ol>
    
          {stage === "resolved" ? (
            <div className="mt-4 flex items-center justify-between rounded-xl bg-success/8 px-3 py-2.5">
              <p className="text-[13px] text-foreground">Final outcome</p>
              <motion.span
                initial={reduceMotion ? false : { scale: 1.7, rotate: -16, opacity: 0 }}
                animate={{ scale: 1, rotate: -6, opacity: 1 }}
                transition={{ type: "spring", stiffness: 380, damping: 17, delay: 0.2 }}
                className="rounded-md border-2 border-success px-2 py-0.5 text-[12px] font-bold tracking-wide text-[color-mix(in_oklab,var(--success)_75%,var(--foreground))] uppercase"
              >
                Resolved: {winner?.name ?? "—"}
              </motion.span>
            </div>
          ) : null}
    
          {stage === "resolving" && disputeEndsAt ? (
            <div className="mt-4 rounded-xl border border-border bg-surface px-3 py-2.5">
              <p className="text-[12px] text-muted-foreground">Dispute window closes in</p>
              <p className="mt-0.5 flex items-baseline gap-1 text-lg font-semibold text-foreground tabular-nums" aria-label={now ? `${days} ${days === 1 ? "day" : "days"}, ${hours} ${hours === 1 ? "hour" : "hours"}, ${minutes} ${minutes === 1 ? "minute" : "minutes"}` : undefined}>
                {now ? (
                  <>
                    <RollingDigits value={days} decimals={0} />
                    <span className="text-[12px] font-normal text-muted-foreground">d</span>
                    <RollingDigits value={hours} decimals={0} />
                    <span className="text-[12px] font-normal text-muted-foreground">h</span>
                    <RollingDigits value={minutes} decimals={0} />
                    <span className="text-[12px] font-normal text-muted-foreground">m</span>
                  </>
                ) : (
                  " "
                )}
              </p>
            </div>
          ) : null}
    
          <dl className="mt-4 grid grid-cols-2 gap-3 text-[12px]">
            <div>
              <dt className="text-muted-foreground">Trading ends</dt>
              <dd className="font-medium text-foreground tabular-nums">{now ? formatDate(market.endsAt) : "\u00a0"}</dd>
            </div>
            <div>
              <dt className="text-muted-foreground">Resolves</dt>
              <dd className="font-medium text-foreground tabular-nums">{now ? formatDate(resolvesAt ?? market.endsAt + 86400_000) : "\u00a0"}</dd>
            </div>
          </dl>
    
          {market.rules ? (
            <div className="mt-4 border-t border-border pt-3">
              <button type="button" aria-expanded={rulesOpen} onClick={() => setRulesOpen((o) => !o)} className="flex w-full cursor-pointer items-center justify-between rounded-md text-sm font-medium text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring">
                Rules
                <ChevronDown className={cn("size-4 text-muted-foreground transition-transform", rulesOpen && "rotate-180")} aria-hidden="true" />
              </button>
              <div className={cn("grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none", rulesOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]")}>
                <div className="overflow-hidden" inert={!rulesOpen}>
                  <p className="pt-2 text-[13px] leading-6 text-muted-foreground">{market.rules}</p>
                  {market.resolutionSource ? (
                    <a href={market.resolutionSource.href} className="mt-2 inline-flex items-center gap-1 rounded-sm text-[13px] font-medium text-foreground underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring">
                      {market.resolutionSource.label}
                      <ExternalLink className="size-3.5" aria-hidden="true" />
                    </a>
                  ) : null}
                </div>
              </div>
            </div>
          ) : null}
        </section>
      );
    }

Usage

import { ResolutionCard } from "@/components/ui/resolution-card/resolution-card";

<ResolutionCard
  market={market} // rules, resolutionSource, endsAt, resolvedOutcomeId
  stage="resolving"
  disputeEndsAt={Date.UTC(2026, 10, 6, 18)}
/>

API reference

PropTypeDefaultDescription
market*Market–The market (rules, resolution source, end time and the winning outcome once resolved).
stageResolutionStage"open"Where the market is in its lifecycle.
disputeEndsAtnumber–When the dispute window closes, ms since epoch (shown while resolving).
resolvesAtnumberthe end time plus a dayExpected resolution time, ms since epoch.
classNamestring–Classes for the card.
  • Stages: open → closed → resolving → resolved. Moving to resolved draws the remaining checks, stamps the winning outcome and briefly glows the border.
  • Dates use a fixed en-US format in UTC and render after hydration, as does the countdown (from a shared 15-second clock).

Accessibility

  • The timeline is an ordered list; the current step has aria-current="step" and every step is labelled in text.
  • The Rules toggle reports aria-expanded, and the collapsed content is inert so it is skipped by Tab.
  • The dispute countdown has a full spoken label ("2 days, 4 hours, 10 minutes"); the final outcome is plain text inside the stamp.
  • With reduced motion the pulse, drawn checks, stamp and border glow are skipped.