Components
Loading preview...
Here is Pricing Cards components
npx shadcn@latest add https://21st.dev/r/lyanchouss/pricing-cards"use client";
import NumberFlow from "@number-flow/react";
import React from "react";
export default function PricingInteraction({
starterMonth = 99,
starterAnnual = 999,
proMonth = 399,
proAnnual = 3999,
}: {
starterMonth?: number;
starterAnnual?: number;
proMonth?: number;
proAnnual?: number;
}) {
const safe = (n: number | undefined) =>
Number.isFinite(Number(n)) ? Number(n) : 0;
const [active, setActive] = React.useState(0);
const [period, setPeriod] = React.useState<0 | 1>(0);
const [starter, setStarter] = React.useState(safe(starterMonth));
const [pro, setPro] = React.useState(safe(proMonth));
const handleChangePlan = (i: number) => setActive(i);
const handleChangePeriod = (i: 0 | 1) => {
setPeriod(i);
if (i === 0) {
setStarter(safe(starterMonth));
setPro(safe(proMonth));
} else {
setStarter(safe(starterAnnual));
setPro(safe(proAnnual));
}
};
// ---- флаг "можно анимировать" после маунта
const [play, setPlay] = React.useState(false);
React.useEffect(() => {
// rAF гарантирует, что DOM уже отрисован
const id = requestAnimationFrame(() => setPlay(true));
return () => cancelAnimationFrame(id);
}, []);
// ---- минимальные частицы
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const setSize = () => {
const rect = canvas.parentElement?.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect?.width ?? window.innerWidth));
canvas.height = Math.max(1, Math.floor(rect?.height ?? window.innerHeight));
};
setSize();
type P = { x: number; y: number; v: number; o: number };
let ps: P[] = [];
let raf = 0;
const make = (): P => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
v: Math.random() * 0.25 + 0.05,
o: Math.random() * 0.35 + 0.15,
});
const init = () => {
ps = [];
const count = Math.floor((canvas.width * canvas.height) / 12000);
for (let i = 0; i < count; i++) ps.push(make());
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ps.forEach((p) => {
p.y -= p.v;
if (p.y < 0) {
p.x = Math.random() * canvas.width;
p.y = canvas.height + Math.random() * 40;
p.v = Math.random() * 0.25 + 0.05;
p.o = Math.random() * 0.35 + 0.15;
}
ctx.fillStyle = `rgba(250,250,250,${p.o})`;
ctx.fillRect(p.x, p.y, 0.7, 2.2);
});
raf = requestAnimationFrame(draw);
};
const onResize = () => {
setSize();
init();
};
const ro = new ResizeObserver(onResize);
ro.observe(canvas.parentElement || document.body);
init();
raf = requestAnimationFrame(draw);
return () => {
ro.disconnect();
cancelAnimationFrame(raf);
};
}, []);
// helper — отдаёт inline-animation значение или начальное состояние
const anim = (name: "drawX" | "drawY" | "fadeUp", delay = 0, dur = 0.6) =>
play
? ({ animation: `${name} ${dur}s ease ${delay}s forwards` } as React.CSSProperties)
: ({} as React.CSSProperties);
return (
<section className="relative min-h-screen w-full overflow-hidden bg-zinc-950 text-zinc-50 grid place-items-center px-4 py-16">
{/* Глобальные keyframes */}
<style>{`
@keyframes drawX { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes drawY { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
`}</style>
{/* Виньетка */}
<div className="pointer-events-none absolute inset-0 [background:radial-gradient(80%_60%_at_50%_12%,rgba(255,255,255,.06),transparent_60%)]" />
{/* Линии (всё инлайном, без nth-child) */}
<div aria-hidden className="absolute inset-0 pointer-events-none opacity-70">
{/* Горизонтальные */}
<div
className="absolute left-0 right-0 bg-[#27272a]"
style={{ top: "18%", height: 1, transform: "scaleX(0)", transformOrigin: "50% 50%", ...anim("drawX", 0.08, 0.6) }}
/>
<div
className="absolute left-0 right-0 bg-[#27272a]"
style={{ top: "50%", height: 1, transform: "scaleX(0)", transformOrigin: "50% 50%", ...anim("drawX", 0.16, 0.6) }}
/>
<div
className="absolute left-0 right-0 bg-[#27272a]"
style={{ top: "82%", height: 1, transform: "scaleX(0)", transformOrigin: "50% 50%", ...anim("drawX", 0.24, 0.6) }}
/>
{/* Вертикальные */}
<div
className="absolute top-0 bottom-0 bg-[#27272a]"
style={{ left: "22%", width: 1, transform: "scaleY(0)", transformOrigin: "50% 0%", ...anim("drawY", 0.20, 0.7) }}
/>
<div
className="absolute top-0 bottom-0 bg-[#27272a]"
style={{ left: "50%", width: 1, transform: "scaleY(0)", transformOrigin: "50% 0%", ...anim("drawY", 0.28, 0.7) }}
/>
<div
className="absolute top-0 bottom-0 bg-[#27272a]"
style={{ left: "78%", width: 1, transform: "scaleY(0)", transformOrigin: "50% 0%", ...anim("drawY", 0.36, 0.7) }}
/>
</div>
{/* Частицы */}
<canvas
ref={canvasRef}
className="absolute inset-0 h-full w-full opacity-50 mix-blend-screen pointer-events-none"
/>
{/* Карточка (fadeUp инлайн) */}
<div
className="relative w-full max-w-md rounded-[28px] border border-zinc-800 bg-zinc-900/70 backdrop-blur supports-[backdrop-filter]:bg-zinc-900/60 shadow-xl p-4"
style={{ opacity: 0, transform: "translateY(12px)", ...anim("fadeUp", 0.32, 0.6) }}
>
{/* Переключатель периода */}
<div className="rounded-full relative w-full bg-zinc-800/50 p-1.5 flex items-center">
<button
className="font-medium rounded-full w-full p-1.5 text-zinc-200 z-20"
onClick={() => handleChangePeriod(0)}
aria-pressed={period === 0}
>
Monthly
</button>
<button
className="font-medium rounded-full w-full p-1.5 text-zinc-200 z-20"
onClick={() => handleChangePeriod(1)}
aria-pressed={period === 1}
>
Yearly
</button>
<div
className="p-1.5 flex items-center justify-center absolute inset-0 w-1/2 z-10"
style={{
transform: `translateX(${period * 100}%)`,
transition: "transform 0.3s",
}}
>
<div className="bg-zinc-900 rounded-full w-full h-full border border-zinc-700 shadow-sm" />
</div>
</div>
{/* Список планов */}
<div className="w-full relative mt-3 flex flex-col items-center justify-center gap-3">
{/* Free */}
<div
className="w-full flex justify-between cursor-pointer border border-zinc-800/80 hover:border-zinc-700 transition-colors p-4 rounded-2xl bg-zinc-900/50"
onClick={() => handleChangePlan(0)}
>
<div className="flex flex-col items-start">
<p className="font-semibold text-xl text-zinc-50">Free</p>
<p className="text-zinc-400 text-sm">
<span className="text-zinc-100 font-medium">$0.00</span>/month
</p>
</div>
<RadioDot active={active === 0} />
</div>
{/* Starter */}
<div
className="w-full flex justify-between cursor-pointer border border-zinc-800/80 hover:border-zinc-700 transition-colors p-4 rounded-2xl bg-zinc-900/50"
onClick={() => handleChangePlan(1)}
>
<div className="flex flex-col items-start">
<p className="font-semibold text-xl flex items-center gap-2 text-zinc-50">
Starter
<span className="py-1 px-2 rounded-lg bg-yellow-100/20 text-yellow-200 text-xs border border-yellow-200/30">
Popular
</span>
</p>
<p className="text-zinc-400 text-sm flex items-baseline gap-1">
<span className="text-zinc-100 font-medium">$</span>
<NumberFlow className="text-zinc-100 font-medium" value={starter} />
<span>/{period === 0 ? "month" : "year"}</span>
</p>
</div>
<RadioDot active={active === 1} />
</div>
{/* Pro */}
<div
className="w-full flex justify-between cursor-pointer border border-zinc-800/80 hover:border-zinc-700 transition-colors p-4 rounded-2xl bg-zinc-900/50"
onClick={() => handleChangePlan(2)}
>
<div className="flex flex-col items-start">
<p className="font-semibold text-xl text-zinc-50">Pro</p>
<p className="text-zinc-400 text-sm flex items-baseline gap-1">
<span className="text-zinc-100 font-medium">$</span>
<NumberFlow className="text-zinc-100 font-medium" value={pro} />
<span>/{period === 0 ? "month" : "year"}</span>
</p>
</div>
<RadioDot active={active === 2} />
</div>
{/* Подсветка выбранного ряда */}
<div
className="pointer-events-none w-full h-[88px] absolute top-0 rounded-2xl border-2 border-zinc-100/80"
style={{
transform: `translateY(${active * 88 + 12 * active}px)`,
transition: "transform 0.3s",
}}
/>
</div>
<button className="mt-4 rounded-full bg-zinc-100 text-lg text-zinc-900 w-full p-3 active:scale-95 transition-transform duration-300 hover:bg-zinc-200">
Get Started
</button>
</div>
</section>
);
}
function RadioDot({ active }: { active: boolean }) {
return (
<div
className="border-2 size-6 rounded-full mt-0.5 p-1 flex items-center justify-center"
style={{ borderColor: active ? "#fff" : "#3f3f46", transition: "border-color 0.3s" }}
>
<div
className="size-3 bg-zinc-100 rounded-full"
style={{ opacity: active ? 1 : 0, transition: "opacity 0.3s" }}
/>
</div>
);
}