Marketing Sections & Logo Cloudsv0.4.0New
Layered Plan Card
A white plan card resting on a colored base that peeks out as the call to action.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/layered-plan-card.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }lib/pricing/billing-toggle.tsx"use client"; import { useId, useRef, type KeyboardEvent } from "react"; import { LayoutGroup, motion, useReducedMotion } from "motion/react"; import { cn } from "@/lib/utils"; import { spring } from "@/lib/motion"; import type { Billing } from "@/lib/pricing/types"; export interface BillingToggleProps { /** The selected billing period. */ value: Billing; /** Called when another period is picked. */ onValueChange: (billing: Billing) => void; /** Periods to offer, in order. @default ["monthly", "yearly"] */ options?: readonly Billing[]; /** Visible text per period. @default { monthly: "Monthly", yearly: "Yearly", once: "One time" } */ labels?: Partial<Record<Billing, string>>; /** Accessible name of the group. @default "Billing period" */ label?: string; /** Control height: 32px or 40px (both keep a 40px hit area). @default "md" */ size?: "sm" | "md"; className?: string; } const DEFAULT_OPTIONS: readonly Billing[] = ["monthly", "yearly"]; const DEFAULT_LABELS: Record<Billing, string> = { monthly: "Monthly", yearly: "Yearly", once: "One time" }; /** A segmented radio group for the billing period, with a pill that glides to the selected option. */ export function BillingToggle({ value, onValueChange, options = DEFAULT_OPTIONS, labels, label = "Billing period", size = "md", className }: BillingToggleProps) { const id = useId(); const reduceMotion = useReducedMotion(); const refs = useRef<(HTMLButtonElement | null)[]>([]); function onKeyDown(event: KeyboardEvent<HTMLDivElement>) { const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0; if (!step) return; event.preventDefault(); const next = (Math.max(0, options.indexOf(value)) + step + options.length) % options.length; onValueChange(options[next]); refs.current[next]?.focus(); } return ( <LayoutGroup id={id}> <div role="radiogroup" aria-label={label} onKeyDown={onKeyDown} className={cn("inline-flex rounded-full border border-border bg-surface p-1", className)} > {options.map((option, index) => { const active = option === value; return ( <button key={option} ref={(node) => { refs.current[index] = node; }} type="button" role="radio" aria-checked={active} tabIndex={active ? 0 : -1} onClick={() => onValueChange(option)} className={cn( "relative inline-flex cursor-pointer items-center rounded-full font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", "after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-['']", size === "sm" ? "h-7 px-3 text-xs" : "h-8 px-4 text-[13px]", active ? "text-background" : "text-foreground/65 hover:text-foreground", )} > {active ? ( <motion.span layoutId="pill" transition={reduceMotion ? { duration: 0 } : spring.snappy} className="absolute inset-0 rounded-full bg-foreground" /> ) : null} <span className="relative">{labels?.[option] ?? DEFAULT_LABELS[option]}</span> </button> ); })} </div> </LayoutGroup> ); }lib/pricing/format.tsexport interface PriceParts { /** The whole formatted price, e.g. "$1,299" or "1.299 €". */ text: string; /** Currency symbol or code, e.g. "$", "€", "Rp". */ symbol: string; /** Everything but the symbol, e.g. "1,299". */ digits: string; /** False when the locale puts the symbol after the number. */ symbolFirst: boolean; /** Fraction digits used. */ decimals: number; } const formatters = new Map<string, Intl.NumberFormat>(); function formatter(currency: string, locale: string, decimals: number) { const key = `${locale}|${currency}|${decimals}`; let found = formatters.get(key); if (!found) { found = new Intl.NumberFormat(locale, { style: "currency", currency, minimumFractionDigits: decimals, maximumFractionDigits: decimals }); formatters.set(key, found); } return found; } /** * Formats a price in major units and splits it into its symbol and digits, so the symbol can be * styled on its own. Whole amounts drop their decimals ("$199", not "$199.00"). The locale is * fixed by the caller, never read from the browser, so server and client output match. */ export function formatPrice(amount: number, currency = "USD", locale = "en-US"): PriceParts { const decimals = Number.isInteger(amount) ? 0 : 2; const parts = formatter(currency, locale, decimals).formatToParts(amount); const symbolIndex = parts.findIndex((part) => part.type === "currency"); const numberIndex = parts.findIndex((part) => part.type === "integer"); const symbol = symbolIndex === -1 ? "" : parts[symbolIndex].value; const digits = parts .filter((part) => part.type !== "currency" && part.type !== "literal") .map((part) => part.value) .join(""); return { text: parts.map((part) => part.value).join(""), symbol, digits, symbolFirst: symbolIndex === -1 || symbolIndex < numberIndex, decimals, }; }lib/pricing/types.tsimport type { ReactNode } from "react"; /** How a plan is billed. `once` is a single payment. */ export type Billing = "monthly" | "yearly" | "once"; export interface Price { /** One amount in major units (199 = $199), or one per billing period (yearly is the per-month equivalent; `once` falls back to monthly). */ amount: number | { monthly: number; yearly: number; once?: number }; /** ISO 4217 currency code, e.g. "USD". */ currency: string; /** Text after the price, e.g. "per user / month" or "One time". */ period?: string; } export interface PlanCta { /** Button label. */ label: string; /** Renders the button as a link when set. */ href?: string; } export interface Plan { /** Stable id, used for keys and selection. */ id: string; name: string; description?: string; /** Small icon shown next to the name. */ icon?: ReactNode; /** Omit for plans without a public price and set `customPrice` instead. */ price?: Price; /** Shown in place of a price, e.g. "Custom quote". */ customPrice?: string; /** Small line near the price, e.g. "Billed yearly". */ priceNote?: string; features: string[]; cta: PlanCta; /** Badge text, e.g. "Popular". */ badge?: string; /** Panel tone, for components that style plans differently. */ tone?: "accent" | "ink" | "neutral"; }lib/pricing/use-billing.ts"use client"; import { useCallback, useState } from "react"; import { formatPrice, type PriceParts } from "@/lib/pricing/format"; import type { Billing, Plan } from "@/lib/pricing/types"; export interface UseBillingOptions { /** Controlled billing period. */ billing?: Billing; /** Initial billing period when uncontrolled. @default "monthly" */ defaultBilling?: Billing; /** Called when the billing period changes. */ onBillingChange?: (billing: Billing) => void; /** Locale for formatting. @default "en-US" */ locale?: string; } export interface PlanPrice extends PriceParts { /** The amount for the current billing period, in major units. */ amount: number; currency: string; /** Props for RollingDigits (the number only, without the symbol), so the price rolls when billing changes. */ rolling: { value: number; decimals: number; locale: string }; } /** The amount a plan charges for a billing period, or null when it has no public price. */ export function planAmount(plan: Plan, billing: Billing): number | null { const amount = plan.price?.amount; if (amount === undefined) return null; if (typeof amount === "number") return amount; if (billing === "once") return amount.once ?? amount.monthly; return billing === "yearly" ? amount.yearly : amount.monthly; } /** True when a plan charges a different amount per billing period, so a billing toggle changes its price. */ export function hasBillingPrices(plan: Plan) { return typeof plan.price?.amount === "object"; } /** * Billing period state, controlled or uncontrolled, plus the price to show for a plan. * `priceFor` returns null for plans with a custom price. */ export function useBilling({ billing, defaultBilling = "monthly", onBillingChange, locale = "en-US" }: UseBillingOptions = {}) { const [internal, setInternal] = useState<Billing>(defaultBilling); const current = billing ?? internal; const setBilling = useCallback( (next: Billing) => { if (billing === undefined) setInternal(next); onBillingChange?.(next); }, [billing, onBillingChange], ); const priceFor = useCallback( (plan: Plan): PlanPrice | null => { const amount = planAmount(plan, current); if (amount === null || !plan.price) return null; const parts = formatPrice(amount, plan.price.currency, locale); return { ...parts, amount, currency: plan.price.currency, rolling: { value: amount, decimals: parts.decimals, locale } }; }, [current, locale], ); return { billing: current, setBilling, priceFor }; }Copy the source
components/ui/layered-plan-card/layered-plan-card.tsx"use client"; import { useState, type ComponentProps, type CSSProperties, type PointerEvent } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { ArrowRight, Check, Star } from "lucide-react"; import { RollingDigits } from "@/components/ui/rolling-digits/rolling-digits"; import { cn } from "@/lib/utils"; import { spring } from "@/lib/motion"; import { BillingToggle } from "@/lib/pricing/billing-toggle"; import type { Billing, Plan } from "@/lib/pricing/types"; import { hasBillingPrices, useBilling } from "@/lib/pricing/use-billing"; export interface LayeredPlanCardProps extends Omit<ComponentProps<"div">, "children" | "onSelect"> { /** The plan to show: name, price, features, badge and call to action. */ plan: Plan; /** Color of the base card, checks and glow, as any CSS color. White text sits on it, so keep it dark enough for 4.5:1. @default "var(--primary)" */ accent?: string; /** Show the blurred glow under the stack. @default true */ glow?: boolean; /** Controlled billing period. Setting it (or `defaultBilling`) shows a monthly/yearly toggle when the plan has a price per period. */ billing?: Billing; /** Initial billing period when uncontrolled. Also shows the toggle. */ defaultBilling?: Billing; /** Called when the billing toggle changes. */ onBillingChange?: (billing: Billing) => void; /** Line under the plan name per billing period, shown while the toggle is in use. @default { monthly: "Billed monthly", yearly: "Billed yearly" } */ billingNotes?: Partial<Record<Billing, string>>; /** Locale for the price. @default "en-US" */ locale?: string; /** Called when the call to action is pressed (when the plan's cta has no href). */ onSelect?: (plan: Plan, billing: Billing) => void; } const NOTES: Record<Billing, string> = { monthly: "Billed monthly", yearly: "Billed yearly", once: "One-time payment" }; export function LayeredPlanCard({ plan, accent = "var(--primary)", glow = true, billing, defaultBilling, onBillingChange, billingNotes, locale = "en-US", onSelect, className, style, ...props }: LayeredPlanCardProps) { const reduceMotion = useReducedMotion(); const state = useBilling({ billing, defaultBilling, onBillingChange, locale }); const showToggle = (billing !== undefined || defaultBilling !== undefined) && hasBillingPrices(plan); const price = state.priceFor(plan); const note = showToggle ? (billingNotes?.[state.billing] ?? NOTES[state.billing]) : plan.priceNote; const [hovered, setHovered] = useState(false); const [pressed, setPressed] = useState(false); // -1 at the card's left edge, 1 at its right: the glow leans toward the pointer. const lean = useMotionValue(0); const glowX = useTransform(useSpring(lean, { stiffness: 160, damping: 22 }), (v) => `${v * 14}%`); function onPointerMove(event: PointerEvent<HTMLDivElement>) { if (reduceMotion || event.pointerType === "touch") return; const rect = event.currentTarget.getBoundingClientRect(); lean.set(((event.clientX - rect.left) / rect.width) * 2 - 1); } // The top card slides up on hover so more of the band shows, and sinks a little while the band is pressed. const lift = reduceMotion ? 0 : (hovered ? -8 : 0) + (pressed ? 2 : 0); const cta = ( <> <span>{plan.cta.label}</span> <motion.span aria-hidden="true" animate={{ x: hovered && !reduceMotion ? 3 : 0 }} transition={spring.snappy} className="inline-flex"> <ArrowRight className="size-[18px]" strokeWidth={2.25} /> </motion.span> </> ); const ctaClass = "flex h-14 w-full cursor-pointer items-center justify-between rounded-b-3xl px-6 text-[15px] font-semibold text-white outline-none transition-[transform,box-shadow] duration-150 active:scale-[0.98] focus-visible:shadow-[inset_0_0_0_4px_var(--plan-accent),inset_0_0_0_6px_white]"; const press = { onPointerDown: () => setPressed(true), onPointerUp: () => setPressed(false), onPointerLeave: () => setPressed(false), onPointerCancel: () => setPressed(false) }; return ( <div onPointerEnter={(event) => { // Hover is for mice and pens; on touch only the press applies. if (event.pointerType !== "touch") setHovered(true); }} onPointerLeave={() => { setHovered(false); lean.set(0); }} onPointerMove={onPointerMove} style={{ "--plan-accent": accent, ...style } as CSSProperties} className={cn("relative isolate w-full max-w-sm", className)} {...props} > {glow ? ( <motion.div aria-hidden="true" style={{ x: glowX, background: "radial-gradient(closest-side, #22d3ee, var(--plan-accent) 55%, transparent)" }} animate={{ opacity: hovered && !reduceMotion ? 0.85 : 0.5 }} transition={{ duration: 0.3 }} className="pointer-events-none absolute inset-x-6 -bottom-7 -z-10 h-24 rounded-[50%] blur-2xl" /> ) : null} <div className="rounded-3xl bg-(--plan-accent) shadow-[0_10px_30px_-12px_color-mix(in_oklab,var(--plan-accent)_70%,transparent)]"> <motion.div animate={{ y: lift }} transition={spring.smooth} className="relative overflow-hidden rounded-3xl border border-black/[0.06] bg-white p-6 text-[#0f1b3d] shadow-[0_1px_2px_rgb(0_0_0/0.05),0_12px_28px_-14px_rgb(15_27_61/0.35)]" > {/* A faint wash of the accent in the top-right corner. */} <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(120% 70% at 100% 0%, color-mix(in oklab, var(--plan-accent) 13%, transparent), transparent 60%)" }} /> <div className="relative flex items-start justify-between gap-3"> <div className="min-w-0"> <h3 className="text-lg font-semibold tracking-tight">{plan.name}</h3> {note ? <p className="mt-0.5 text-[13px] text-[#5b6478]">{note}</p> : null} </div> {plan.badge ? ( <span className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full bg-(--plan-accent) pr-2.5 pl-1 text-[11px] font-bold tracking-[0.06em] text-white uppercase"> <span aria-hidden="true" className="grid size-5 place-items-center rounded-full bg-white/25"> <Star className="size-3" fill="currentColor" strokeWidth={0} /> </span> {plan.badge} </span> ) : null} </div> {showToggle ? <BillingToggle value={state.billing} onValueChange={state.setBilling} size="sm" className="relative mt-4" /> : null} <hr className="relative my-5 border-black/[0.08]" /> <p className="relative text-[13px] font-medium text-[#5b6478]">Includes</p> <ul className="relative mt-3 space-y-2.5 text-[14px]"> {plan.features.map((feature) => ( <li key={feature} className="flex items-start gap-2.5"> <span aria-hidden="true" className="mt-px grid size-5 shrink-0 place-items-center rounded-full bg-[color-mix(in_oklab,var(--plan-accent)_13%,white)] text-(--plan-accent)" > <Check className="size-3" strokeWidth={3.5} /> </span> {feature} </li> ))} </ul> <div className="relative mt-6"> {price ? ( <p className="flex items-start text-5xl leading-none font-semibold tracking-tight"> {price.symbolFirst ? <span className="mt-1 mr-0.5 text-3xl text-[color-mix(in_oklab,var(--plan-accent)_78%,white)]">{price.symbol}</span> : null} <RollingDigits {...price.rolling} animateOnMount={false} /> {price.symbolFirst ? null : <span className="mt-1 ml-1 text-3xl text-[color-mix(in_oklab,var(--plan-accent)_78%,white)]">{price.symbol}</span>} </p> ) : plan.customPrice ? ( <p className="text-3xl leading-none font-semibold tracking-tight">{plan.customPrice}</p> ) : null} {price && plan.price?.period ? <p className="mt-2 text-[13px] text-[#5b6478]">{plan.price.period}</p> : null} </div> </motion.div> {plan.cta.href ? ( <a href={plan.cta.href} className={ctaClass} {...press}> {cta} </a> ) : ( <button type="button" onClick={() => onSelect?.(plan, state.billing)} className={ctaClass} {...press}> {cta} </button> )} </div> </div> ); }
Usage
import { LayeredPlanCard } from "@/components/ui/layered-plan-card/layered-plan-card";
import type { Plan } from "@/lib/pricing/types";
const pro: Plan = {
id: "pro",
name: "Pro",
badge: "Popular",
price: { amount: { monthly: 24, yearly: 19 }, currency: "USD", period: "per user / month" },
features: ["Unlimited projects", "Advanced permissions", "Priority support"],
cta: { label: "Upgrade to Pro", href: "/checkout" },
};
export function Upgrade() {
return <LayeredPlanCard plan={pro} defaultBilling="yearly" />;
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
plan* | Plan | – | The plan to show: name, price, features, badge and call to action. |
accent | string | "var(--primary)" | Color of the base card, checks and glow, as any CSS color. White text sits on it, so keep it dark enough for 4.5:1. |
glow | boolean | true | Show the blurred glow under the stack. |
billing | Billing | – | Controlled billing period. Setting it (or `defaultBilling`) shows a monthly/yearly toggle when the plan has a price per period. |
defaultBilling | Billing | – | Initial billing period when uncontrolled. Also shows the toggle. |
onBillingChange | (billing: Billing) => void | – | Called when the billing toggle changes. |
billingNotes | Partial<Record<Billing, string>> | { monthly: "Billed monthly", yearly: "Billed yearly" } | Line under the plan name per billing period, shown while the toggle is in use. |
locale | string | "en-US" | Locale for the price. |
onSelect | (plan: Plan, billing: Billing) => void | – | Called when the call to action is pressed (when the plan's cta has no href). |
Also accepts every native <div> attribute, including ref and className.
- The card is two layers: the base card in the accent color, and a white card on top of it. The part of the base that shows below is the call to action.
- Hovering slides the top card up 8px so more of the band shows, and pressing the band sinks it 2px. Hover effects are skipped on touch, where only the press applies.
- The toggle appears when billing or defaultBilling is set and the plan's price has monthly and yearly amounts. Without it, priceNote is shown under the name.
- accent takes any CSS color. White text sits on it and the checks use it on a light tint, so pick one with at least 4.5:1 against white.
- The glow is a blurred CSS gradient under the stack, not a canvas. Leave room below the card for it, or turn it off with glow={false}.
Accessibility
- The band at the bottom is a real link (when cta.href is set) or a button, with a white focus outline inside the band.
- Features are a list, the plan name is a heading and the badge is plain text.
- The billing toggle is a radio group: arrow keys move between periods and the selected one is the only tab stop.
- The price is announced once as its full text; the rolling reels are hidden from assistive tech.
- With reduced motion enabled, the card does not lift and the glow does not move or brighten. Prices swap instantly.