E-commerce

Product Price

A sale price with a compare-at original that draws its strikethrough on mount, plus an optional savings badge.

Install

npx shadcn@latest add @paragon/product-price

product-price.tsx

"use client";

import * as React from "react";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

export interface ProductPriceProps
  extends Omit<React.ComponentProps<"span">, "children"> {
  /** Current (selling) price. */
  price: number;
  /** Original price; when higher than `price`, renders struck through. */
  compareAt?: number;
  currency?: string;
  locale?: Intl.LocalesArgument;
  /** Show the "Save X%" badge when on sale. */
  showSavings?: boolean;
  /** Roll the price up on mount / when it changes. */
  animate?: boolean;
  /** Disables the strike-through draw and number roll; renders final state. */
  static?: boolean;
}

/**
 * A price line. The selling price rolls to its value on a spring (tabular, so
 * the layout never jitters). When `compareAt` is above `price`, the original is
 * shown with a strikethrough that draws in on mount (left-to-right), and an
 * optional "Save N%" badge fades up carrying the exact amount saved in a
 * tooltip. Reduced motion / `static` render the strike fully drawn and the
 * numbers at rest.
 */
export function ProductPrice({
  price,
  compareAt,
  currency = "USD",
  locale = "en-US",
  showSavings = true,
  animate = true,
  static: isStatic = false,
  className,
  ...props
}: ProductPriceProps) {
  const format = React.useMemo(
    () =>
      new Intl.NumberFormat(locale, {
        style: "currency",
        currency,
      }),
    [locale, currency],
  );

  const currencyOptions = React.useMemo<Intl.NumberFormatOptions>(
    () => ({ style: "currency", currency }),
    [currency],
  );

  const onSale = compareAt !== undefined && compareAt > price;
  const savings = onSale
    ? Math.round(((compareAt - price) / compareAt) * 100)
    : 0;
  const saved = onSale ? compareAt - price : 0;
  const roll = animate && !isStatic;

  return (
    <>
      <style href="paragon-product-price" precedence="paragon">{`
        @keyframes pg-price-strike {
          from { transform: scaleX(0); }
          to { transform: scaleX(1); }
        }
        @keyframes pg-price-badge {
          from { opacity: 0; transform: translateY(4px); filter: blur(4px); }
          to { opacity: 1; transform: translateY(0); filter: blur(0); }
        }
        @media (prefers-reduced-motion: reduce) {
          .pg-price-strike { animation: none !important; transform: scaleX(1); }
          .pg-price-badge { animation: none !important; opacity: 1; transform: none; filter: none; }
        }
      `}</style>
      <span
        className={cn("inline-flex items-center gap-2", className)}
        {...props}
      >
        <span
          className={cn(
            "text-base font-semibold tabular-nums",
            onSale && "text-destructive",
          )}
        >
          <NumberTicker
            value={price}
            static={!roll}
            locale={locale}
            formatOptions={currencyOptions}
          />
        </span>
        {onSale && (
          <span className="relative inline-block text-sm text-muted-foreground tabular-nums">
            {format.format(compareAt)}
            <span
              aria-hidden
              className="pg-price-strike absolute left-0 top-1/2 h-px w-full origin-left -translate-y-1/2 bg-muted-foreground"
              style={{
                animation: isStatic
                  ? undefined
                  : "pg-price-strike 300ms var(--ease-out) 100ms both",
              }}
            />
          </span>
        )}
        {onSale && showSavings && (
          <TooltipProvider>
            <Tooltip>
              <TooltipTrigger asChild>
                <span
                  tabIndex={0}
                  className="pg-price-badge inline-flex cursor-default items-center rounded-md bg-success/12 px-1.5 py-0.5 text-xs font-medium text-success tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                  style={{
                    animation: isStatic
                      ? undefined
                      : "pg-price-badge 250ms var(--ease-out) 200ms both",
                  }}
                >
                  Save {savings}%
                </span>
              </TooltipTrigger>
              <TooltipContent>
                You save {format.format(saved)}
              </TooltipContent>
            </Tooltip>
          </TooltipProvider>
        )}
      </span>
    </>
  );
}