"use client"; /** * PricingCard, from https://motion-brew.com/components/pricing-card * * One self contained file: the component, its animated icons and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. * * The animated icons are adapted from lucide-animated (https://lucide-animated.com), * MIT License, Copyright (c) 2024-2026 pqoqubbw. Icon shapes from Lucide * (https://lucide.dev), ISC License. Permission is hereby granted, free of * charge, to any person obtaining a copy of this software, to deal in it * without restriction, provided the above copyright notice and this * permission notice are included in all copies or substantial portions. */ import * as React from "react"; import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react"; import { type ClassValue, clsx } from "clsx"; import { ArrowRight } from "lucide-react"; import { AnimatePresence, motion, type Transition, useAnimation, useReducedMotion, type Variants } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Animated icon: archive const ArchiveIcon = (() => { interface ArchiveIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface ArchiveIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const RECT_VARIANTS: Variants = { normal: { translateY: 0, transition: { duration: 0.2, type: "spring", stiffness: 200, damping: 25, }, }, animate: { translateY: -1.5, transition: { duration: 0.2, type: "spring", stiffness: 200, damping: 25, }, }, }; const PATH_VARIANTS: Variants = { normal: { d: "M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8" }, animate: { d: "M4 11v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V11" }, }; const SECONDARY_PATH_VARIANTS: Variants = { normal: { d: "M10 12h4" }, animate: { d: "M10 15h4" }, }; const ArchiveIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ArchiveIcon.displayName = "ArchiveIcon"; return ArchiveIcon; })(); // Animated icon: folder-open const FolderOpenIcon = (() => { interface FolderOpenIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface FolderOpenIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const VARIANTS: Variants = { normal: { rotate: 0 }, animate: { rotate: [0, -8, 6, -4, 0], transition: { ease: "easeInOut", rotate: { duration: 0.6, }, }, }, }; const FolderOpenIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); FolderOpenIcon.displayName = "FolderOpenIcon"; return FolderOpenIcon; })(); // Animated icon: mail-check const MailCheckIcon = (() => { interface MailCheckIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface MailCheckIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const CHECK_VARIANTS: Variants = { normal: { pathLength: 1, opacity: 1, transition: { duration: 0.3, }, }, animate: { pathLength: [0, 1], opacity: [0, 1], transition: { pathLength: { duration: 0.4, ease: "easeInOut" }, opacity: { duration: 0.4, ease: "easeInOut" }, }, }, }; const MailCheckIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); MailCheckIcon.displayName = "MailCheckIcon"; return MailCheckIcon; })(); // Animated icon: sparkles const SparklesIcon = (() => { interface SparklesIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface SparklesIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const SPARKLE_VARIANTS: Variants = { initial: { y: 0, fill: "none", }, hover: { y: [0, -1, 0, 0], fill: "currentColor", transition: { duration: 1, bounce: 0.3, }, }, }; const STAR_VARIANTS: Variants = { initial: { opacity: 1, x: 0, y: 0, }, blink: () => ({ opacity: [0, 1, 0, 0, 0, 0, 1], transition: { duration: 2, type: "spring", stiffness: 70, damping: 10, mass: 0.4, }, }), }; const SparklesIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const starControls = useAnimation(); const sparkleControls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => { sparkleControls.start("hover"); starControls.start("blink", { delay: 1 }); }, stopAnimation: () => { sparkleControls.start("initial"); starControls.start("initial"); }, }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { sparkleControls.start("hover"); starControls.start("blink", { delay: 1 }); } }, [onMouseEnter, sparkleControls, starControls] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { sparkleControls.start("initial"); starControls.start("initial"); } }, [sparkleControls, starControls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); SparklesIcon.displayName = "SparklesIcon"; return SparklesIcon; })(); // Animated icon: trending-up const TrendingUpIcon = (() => { interface TrendingUpIconHandle { startAnimation: () => void; stopAnimation: () => void; } interface TrendingUpIconProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const SVG_VARIANTS: Variants = { animate: { x: 0, y: 0, translateX: [0, 2, 0], translateY: [0, -2, 0], transition: { duration: 0.5, }, }, }; const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [1, 0], transition: { duration: 0.4, opacity: { duration: 0.1 }, }, }, }; const ARROW_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { delay: 0.3, duration: 0.3, opacity: { duration: 0.1, delay: 0.3 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], pathOffset: [0.5, 0], transition: { delay: 0.3, duration: 0.3, opacity: { duration: 0.1, delay: 0.3 }, }, }, }; const TrendingUpIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); TrendingUpIcon.displayName = "TrendingUpIcon"; return TrendingUpIcon; })(); // Animated icon: zap const ZapIcon = (() => { interface ZapHandle { startAnimation: () => void; stopAnimation: () => void; } interface ZapProps extends React.HTMLAttributes { /** Size in px; by default it follows className, like a lucide icon. */ size?: number; /** Play while true; when set, hover no longer drives it. */ active?: boolean; /** Line weight, like a lucide icon's. Default `2`. */ strokeWidth?: number | string; } const PATH_VARIANTS: Variants = { normal: { opacity: 1, pathLength: 1, transition: { duration: 0.6, opacity: { duration: 0.1 }, }, }, animate: { opacity: [0, 1], pathLength: [0, 1], transition: { duration: 0.6, opacity: { duration: 0.1 }, }, }, }; const ZapIcon = forwardRef( ({ onMouseEnter, onMouseLeave, className, size, active, style, strokeWidth, ...props }, ref) => { const controls = useAnimation(); const isControlledRef = useRef(false); useImperativeHandle(ref, () => { isControlledRef.current = true; return { startAnimation: () => controls.start("animate"), stopAnimation: () => controls.start("normal"), }; }); const handleMouseEnter = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseEnter?.(e); } else { controls.start("animate"); } }, [controls, onMouseEnter] ); const handleMouseLeave = useCallback( (e: React.MouseEvent) => { if (isControlledRef.current) { onMouseLeave?.(e); } else { controls.start("normal"); } }, [controls, onMouseLeave] ); // Play with the nearest interactive parent, or whenever `active` is set. // Reduced motion keeps the icon still. const wrap = useRef(null); const reduced = useReducedMotion(); const [hosted, setHosted] = useState(false); useEffect(() => { const fake = {} as React.MouseEvent; if (reduced) { setHosted(true); return; } if (active !== undefined) { setHosted(true); (active ? handleMouseEnter : handleMouseLeave)(fake); return; } const host = wrap.current?.parentElement?.closest("button, a, [role=button], [role=menuitem], [role=tab], [role=switch], [role=radio], label"); setHosted(!!host); if (!host) return; const on = () => handleMouseEnter(fake); const off = () => handleMouseLeave(fake); host.addEventListener("pointerenter", on); host.addEventListener("pointerleave", off); return () => { host.removeEventListener("pointerenter", on); host.removeEventListener("pointerleave", off); }; }, [active, reduced, handleMouseEnter, handleMouseLeave]); return ( svg]:size-full", className)} style={size ? { width: size, height: size, ...style } : style} onMouseEnter={hosted ? undefined : handleMouseEnter} onMouseLeave={hosted ? undefined : handleMouseLeave} {...props} > ); } ); ZapIcon.displayName = "ZapIcon"; return ZapIcon; })(); // PricingCard /** * PricingCard * * Trigger: hover (plus press and in view). One plan as a white card resting in * a soft grey shell, like a print in a mount, with a label strip along the top * of the shell. Hover or focus the card and the shell warms with a slowly * drifting pastel gradient, the card lifts, the strip rolls up from below to a * quiet "Recommended for you" with a sparkle, the plan icon plays and the * button turns ink; leaving lets it all settle back. A small Yearly switch sits * beside the price: flip it and the knob slides, a saving chip pops in, the * price rolls like an odometer and the billing line blurs to match. Each * feature's icon plays when you point at its row. When the card scrolls into * view the shell rises in, the white card settles into it, the plan icon plays * once and the features follow in a short cascade. Reduced motion keeps every * state without movement. * * Engine: motion/react (springs for the lift, knob and digit columns, a * looping gradient drift, AnimatePresence for the rolling strip and billing * line, staggered in view entrances). */ /** An icon that takes className, strokeWidth and an `active` flag, like the animated icons. */ export type PricingIcon = React.ComponentType<{ className?: string; strokeWidth?: number; active?: boolean; }>; export interface PricingFeature { title: string; /** A short line under the title. */ detail?: string; icon?: PricingIcon; } export interface PricingPlan { name: string; /** The label strip along the top of the shell. */ label: string; /** One line under the name. */ blurb: string; /** Price per month when billed monthly. */ monthly: number; /** Price per month when billed yearly. */ yearly: number; features: PricingFeature[]; cta: string; /** A small line under the button. */ footnote?: string; icon?: PricingIcon; } export interface PricingCardProps { plan?: PricingPlan; /** Billing shown first. Default `"monthly"`. */ defaultBilling?: "monthly" | "yearly"; /** What the strip rolls to on hover. Default `"Recommended for you"`. */ note?: string; /** Called by the button. */ onSelect?: (billing: "monthly" | "yearly") => void; className?: string; } const SPRING: Transition = { type: "spring", stiffness: 420, damping: 34 }; const KNOB: Transition = { type: "spring", stiffness: 600, damping: 34 }; const ROLL: Transition = { type: "spring", stiffness: 220, damping: 26 }; const EASE = [0.22, 1, 0.36, 1] as const; const DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const GLOW = "linear-gradient(120deg, #e9d5ff, #c7d7fe, #fbcfe8, #fde7c2, #e9d5ff)"; const DEFAULT_PLAN: PricingPlan = { name: "Personal", label: "Ideal for solo makers", blurb: "Everything you need to ship your own projects.", monthly: 12, yearly: 9, features: [ { title: "Unlimited projects", detail: "Start as many as you like.", icon: FolderOpenIcon, }, { title: "Simple analytics", detail: "Visits, sources and top pages.", icon: TrendingUpIcon, }, { title: "10 GB storage", detail: "Room for files and builds.", icon: ArchiveIcon, }, { title: "Email support", detail: "Answers within a working day.", icon: MailCheckIcon, }, ], cta: "Get started", footnote: "14 day free trial, no card needed", icon: ZapIcon, }; function Digit({ digit, still }: { digit: number; still: boolean }) { return ( {DIGITS.map((d) => ( {d} ))} ); } /** A number written as rolling digit columns, keyed from the right. */ function Rolling({ value, still }: { value: number; still: boolean }) { const chars = Array.from(String(Math.round(value))); return ( {chars.map((ch, i) => ( ))} ); } function Feature({ feature, delay, hovered, onHover, uid, still, }: { feature: PricingFeature; delay: number; hovered: boolean; onHover: (on: boolean) => void; uid: string; still: boolean; }) { const Icon = feature.icon; return ( onHover(true)} onPointerLeave={() => onHover(false)} className="relative -mx-2.5 flex gap-3 rounded-2xl px-2.5 py-2.5" > {/* A soft highlight glides from row to row under the pointer. */} {hovered && ( )} {Icon ? ( ) : ( )} {feature.title} {feature.detail && ( {feature.detail} )} ); } export const PricingCard = React.forwardRef( function PricingCard( { plan = DEFAULT_PLAN, defaultBilling = "monthly", note = "Recommended for you", onSelect, className, }, ref, ) { const still = !!useReducedMotion(); const [billing, setBilling] = React.useState<"monthly" | "yearly">( defaultBilling, ); const [lit, setLit] = React.useState(false); const [intro, setIntro] = React.useState(false); const [row, setRow] = React.useState(null); const uid = React.useId(); const yearly = billing === "yearly"; const price = yearly ? plan.yearly : plan.monthly; const save = plan.monthly > 0 ? Math.round((1 - plan.yearly / plan.monthly) * 100) : 0; const Icon = plan.icon; return ( { // The plan icon plays once as the card arrives. setIntro(true); window.setTimeout(() => setIntro(false), 1200); }} className={cn("w-[360px] max-w-full", className)} > e.pointerType === "mouse" && setLit(true)} onPointerLeave={(e) => e.pointerType === "mouse" && setLit(false)} onFocus={() => setLit(true)} onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setLit(false); }} animate={{ y: lit && !still ? -6 : 0, boxShadow: lit ? "0 1px 2px rgba(0,0,0,0.04), 0 28px 56px -28px rgba(80,60,160,0.35)" : "0 1px 2px rgba(0,0,0,0.03), 0 12px 32px -20px rgba(0,0,0,0.16)", }} transition={SPRING} className="relative overflow-hidden rounded-[30px] bg-[#f1f1ef] p-1.5 text-[#242423] outline-none focus-visible:ring-2 focus-visible:ring-zinc-400" > {/* The shell's glow: a pastel gradient drifting slowly, shown on hover. */} {/* The strip: the plan's label, rolling up to the note on hover. */}
{lit && ( )} {lit ? note : plan.label}
{/* The white card, settling into its shell. */}
{Icon && ( )}

{plan.name}

{plan.blurb}

{/* Price, with the billing switch beside it. */}
$ / month {yearly ? `$${plan.yearly * 12} billed once a year` : "Billed monthly, cancel any time"}
{plan.footnote && (

{plan.footnote}

)}
    setRow(null)} > {plan.features.map((f, k) => ( setRow(on ? k : null)} uid={uid} still={still} /> ))}
); }, );