A sale price with a compare-at original that draws its strikethrough on mount, plus an optional savings badge.
npx shadcn@latest add @paragon/product-price"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>
</>
);
}