Pain Points
FeaturesThe 'problem' section for a marketing page — a grid of pain-point cards, each with an animated mini-visual that plays on scroll, closing on a rotating 'nothing compounds' finale. Self-contained theme (amber accent via `brand`, Pixelify display font loaded by the block); light-mode. Composes text-reveal.
1280pxOpen
components/blocks/pain-points.tsx
"use client";
import { Fragment, type CSSProperties, useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { Infinity as InfinityIcon } from "lucide-react";
import { TextReveal } from "@/registry/ui/text-reveal";
// Self-contained theme — accent via a local `--brand`, display font loaded by
// the block itself (no project config). See the family convention.
const DISPLAY_FONT = '"Pixelify Sans", ui-sans-serif, system-ui, sans-serif';
const display: CSSProperties = { fontFamily: DISPLAY_FONT };
type Visual = "search" | "chart" | "clock" | "race";
type Pain = { title: string; subtext: string; visual: Visual };
const pains: Pain[] = [
{
title: "Search is changing faster than you can keep up.",
subtext:
"AI answers steal clicks before users even reach your site. Fewer clicks. Less visibility. Same traffic, gone.",
visual: "search",
},
{
title: "You don’t lack data — you lack direction.",
subtext:
"You jump between tools, export reports, stare at numbers… and still don’t know what to do next.",
visual: "chart",
},
{
title: "Everything takes longer than it should.",
subtext:
"One blog = keyword research → outline → writing → optimization → internal links → publishing. It eats hours. Every single time.",
visual: "clock",
},
{
title: "And while you’re figuring it out…",
subtext: "someone else already shipped 10 pages and took your spot.",
visual: "race",
},
];
function MiniVisual({ kind, inView }: { kind: Visual; inView: boolean }) {
const common = "absolute inset-0 h-full w-full";
if (kind === "search") {
return (
<svg viewBox="0 0 160 80" className={common} aria-hidden>
<defs>
<linearGradient id="pp-fade" x1="0" x2="1">
<stop offset="0" stopColor="var(--brand)" stopOpacity="0.9" />
<stop offset="1" stopColor="var(--brand)" stopOpacity="0.1" />
</linearGradient>
</defs>
<rect x="8" y="14" width="110" height="8" rx="4" fill="#000" opacity="0.08" />
<rect x="8" y="30" width="86" height="6" rx="3" fill="#000" opacity="0.06" />
<rect x="8" y="44" width="70" height="6" rx="3" fill="#000" opacity="0.05" />
<rect x="8" y="58" width="54" height="6" rx="3" fill="#000" opacity="0.04" />
<motion.rect
x="0"
y="0"
width="160"
height="80"
fill="url(#pp-fade)"
initial={{ x: -160 }}
animate={inView ? { x: 0 } : { x: -160 }}
transition={{ duration: 1.1, ease: "easeOut" }}
opacity="0.35"
/>
<g transform="translate(118,16)">
<rect width="34" height="14" rx="7" fill="var(--brand)" />
<text x="17" y="10" textAnchor="middle" fontSize="8" fill="#fff" fontWeight="700">
AI
</text>
</g>
</svg>
);
}
if (kind === "chart") {
const bars = [28, 52, 18, 44, 12, 36, 22];
return (
<svg viewBox="0 0 160 80" className={common} aria-hidden>
{bars.map((h, i) => (
<motion.rect
key={i}
x={10 + i * 21}
width="12"
rx="2"
fill="var(--brand)"
opacity={0.25 + (i % 3) * 0.15}
initial={{ y: 70, height: 0 }}
animate={inView ? { y: 70 - h, height: h } : { y: 70, height: 0 }}
transition={{ delay: i * 0.05, duration: 0.5, ease: "easeOut" }}
/>
))}
<line x1="4" y1="70" x2="156" y2="70" stroke="#000" strokeOpacity="0.1" />
</svg>
);
}
if (kind === "clock") {
const steps = ["research", "outline", "write", "optimize", "links", "publish"];
return (
<div className={`${common} flex flex-wrap items-center justify-center gap-1.5 p-3`}>
{steps.map((s, i) => (
<Fragment key={s}>
<motion.span
initial={{ opacity: 0, y: 4 }}
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 4 }}
transition={{ delay: i * 0.06 }}
className="rounded-md border border-black/10 bg-white px-2 py-0.5 text-[10px] font-medium text-black/60 shadow-[0_1px_0_rgba(0,0,0,0.04)]"
>
{s}
</motion.span>
{i < steps.length - 1 && (
<span className="text-[10px] text-[color-mix(in_oklab,var(--brand)_60%,transparent)]">
→
</span>
)}
</Fragment>
))}
</div>
);
}
// race
return (
<svg viewBox="0 0 160 80" className={common} aria-hidden>
<line x1="0" y1="56" x2="160" y2="56" stroke="#000" strokeOpacity="0.08" strokeDasharray="3 3" />
<motion.g
initial={{ x: 0 }}
animate={inView ? { x: 110 } : { x: 0 }}
transition={{ duration: 1.4, ease: "easeOut" }}
>
<rect x="0" y="44" width="28" height="14" rx="3" fill="var(--brand)" />
<text x="14" y="54" textAnchor="middle" fontSize="8" fill="#fff" fontWeight="700">
them
</text>
</motion.g>
<motion.g
initial={{ x: 0 }}
animate={inView ? { x: 30 } : { x: 0 }}
transition={{ duration: 1.4, ease: "easeOut" }}
>
<rect x="0" y="62" width="28" height="14" rx="3" fill="#000" opacity="0.35" />
<text x="14" y="72" textAnchor="middle" fontSize="8" fill="#fff" fontWeight="700">
you
</text>
</motion.g>
</svg>
);
}
function PainCard({ pain, index }: { pain: Pain; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.3 });
return (
<div className="h-full">
<div
ref={ref}
className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-transparent p-5 transition-all duration-300 hover:border-black/10 hover:shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
style={display}
className="text-[11px] tracking-widest text-[color-mix(in_oklab,var(--brand)_70%,transparent)]"
>
0{index + 1}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-[color-mix(in_oklab,var(--brand)_20%,transparent)] to-transparent" />
</div>
<p className="mt-3 text-[17px] leading-6 font-semibold text-black/90">{pain.title}</p>
<p className="mt-2 text-sm leading-6 text-black/55">{pain.subtext}</p>
</div>
<div className="relative mt-5 h-20 shrink-0 overflow-hidden rounded-xl bg-black/[0.02] ring-1 ring-black/5">
<MiniVisual kind={pain.visual} inView={inView} />
</div>
</div>
</div>
);
}
/**
* "The problem" section for a marketing page — a grid of pain-point cards, each
* with an animated mini-visual that plays on scroll, closing on a rotating
* "nothing compounds" finale. Self-contained theme (amber accent via `brand`,
* Pixelify display font loaded by the block); light-mode. Composes text-reveal.
*/
export function PainPoints({ brand = "#FF4701" }: { brand?: string }) {
const reduce = useReducedMotion();
return (
<section
className="relative overflow-hidden bg-[#FEFEF5] px-5 py-16 text-neutral-950 sm:px-8 sm:py-20"
style={{ ["--brand" as string]: brand }}
>
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&display=swap"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 [background-image:radial-gradient(rgba(0,0,0,0.04)_1px,transparent_1px)] [background-size:22px_22px] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_75%)]"
/>
<div
aria-hidden
className="pointer-events-none absolute top-1/3 left-1/2 -z-10 h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--brand)_10%,transparent)] blur-[120px]"
/>
<div className="relative mx-auto max-w-6xl">
<div className="max-w-3xl">
<TextReveal
as="h2"
style={display}
className="text-3xl leading-tight tracking-tight sm:text-4xl md:text-5xl"
>
SEO isn't hard.
<br />
<span className="text-black/45">It's just exhausting.</span>
</TextReveal>
<TextReveal delay={0.1} className="mt-3 text-base leading-7 text-black/65 sm:text-lg">
You're not losing because you're bad at SEO. You're losing because the
game changed — and the old playbook still demands all your time.
</TextReveal>
</div>
<div className="relative mt-10 grid grid-cols-1 *:border-b *:border-black/5 sm:grid-cols-2 [&>*:last-child]:border-b-0 sm:[&>*:nth-child(odd)]:border-r sm:[&>*:nth-child(odd)]:border-black/5 sm:[&>*:nth-last-child(-n+2)]:border-b-0">
{pains.map((pain, i) => (
<PainCard key={pain.title} pain={pain} index={i} />
))}
</div>
<div className="mt-4 flex justify-center" aria-hidden>
<div className="h-8 w-px bg-linear-to-b from-[color-mix(in_oklab,var(--brand)_40%,transparent)] to-transparent" />
</div>
<div className="relative mt-2 p-4 sm:p-6">
<div className="relative flex flex-col items-center gap-6 text-center">
<motion.span
animate={reduce ? undefined : { rotate: 360 }}
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-[var(--brand)] text-white shadow-[0_10px_30px_-10px_color-mix(in_oklab,var(--brand)_55%,transparent)] ring-1 ring-[color-mix(in_oklab,var(--brand)_20%,transparent)]"
>
<InfinityIcon size={26} />
</motion.span>
<div>
<span
style={display}
className="text-[11px] tracking-widest text-[var(--brand)]"
>
THE TRAP
</span>
<p
style={display}
className="mt-2 text-2xl leading-tight text-neutral-950 sm:text-3xl md:text-4xl"
>
So you stay busy — but nothing compounds.
</p>
<p className="mx-auto mt-3 max-w-xl text-base leading-7 text-black/60">
You're stuck in the cycle of doing SEO, but never seeing SEO work.
</p>
</div>
</div>
</div>
</div>
</section>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/pain-points.jsonInstalls the block and its component dependencies (text-reveal) in one step.
This block uses the text-reveal component — add it first.
Install dependencies
Terminal
npm install motion lucide-reactCopy the source
components/blocks/pain-points.tsx
"use client";
import { Fragment, type CSSProperties, useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { Infinity as InfinityIcon } from "lucide-react";
import { TextReveal } from "@/registry/ui/text-reveal";
// Self-contained theme — accent via a local `--brand`, display font loaded by
// the block itself (no project config). See the family convention.
const DISPLAY_FONT = '"Pixelify Sans", ui-sans-serif, system-ui, sans-serif';
const display: CSSProperties = { fontFamily: DISPLAY_FONT };
type Visual = "search" | "chart" | "clock" | "race";
type Pain = { title: string; subtext: string; visual: Visual };
const pains: Pain[] = [
{
title: "Search is changing faster than you can keep up.",
subtext:
"AI answers steal clicks before users even reach your site. Fewer clicks. Less visibility. Same traffic, gone.",
visual: "search",
},
{
title: "You don’t lack data — you lack direction.",
subtext:
"You jump between tools, export reports, stare at numbers… and still don’t know what to do next.",
visual: "chart",
},
{
title: "Everything takes longer than it should.",
subtext:
"One blog = keyword research → outline → writing → optimization → internal links → publishing. It eats hours. Every single time.",
visual: "clock",
},
{
title: "And while you’re figuring it out…",
subtext: "someone else already shipped 10 pages and took your spot.",
visual: "race",
},
];
function MiniVisual({ kind, inView }: { kind: Visual; inView: boolean }) {
const common = "absolute inset-0 h-full w-full";
if (kind === "search") {
return (
<svg viewBox="0 0 160 80" className={common} aria-hidden>
<defs>
<linearGradient id="pp-fade" x1="0" x2="1">
<stop offset="0" stopColor="var(--brand)" stopOpacity="0.9" />
<stop offset="1" stopColor="var(--brand)" stopOpacity="0.1" />
</linearGradient>
</defs>
<rect x="8" y="14" width="110" height="8" rx="4" fill="#000" opacity="0.08" />
<rect x="8" y="30" width="86" height="6" rx="3" fill="#000" opacity="0.06" />
<rect x="8" y="44" width="70" height="6" rx="3" fill="#000" opacity="0.05" />
<rect x="8" y="58" width="54" height="6" rx="3" fill="#000" opacity="0.04" />
<motion.rect
x="0"
y="0"
width="160"
height="80"
fill="url(#pp-fade)"
initial={{ x: -160 }}
animate={inView ? { x: 0 } : { x: -160 }}
transition={{ duration: 1.1, ease: "easeOut" }}
opacity="0.35"
/>
<g transform="translate(118,16)">
<rect width="34" height="14" rx="7" fill="var(--brand)" />
<text x="17" y="10" textAnchor="middle" fontSize="8" fill="#fff" fontWeight="700">
AI
</text>
</g>
</svg>
);
}
if (kind === "chart") {
const bars = [28, 52, 18, 44, 12, 36, 22];
return (
<svg viewBox="0 0 160 80" className={common} aria-hidden>
{bars.map((h, i) => (
<motion.rect
key={i}
x={10 + i * 21}
width="12"
rx="2"
fill="var(--brand)"
opacity={0.25 + (i % 3) * 0.15}
initial={{ y: 70, height: 0 }}
animate={inView ? { y: 70 - h, height: h } : { y: 70, height: 0 }}
transition={{ delay: i * 0.05, duration: 0.5, ease: "easeOut" }}
/>
))}
<line x1="4" y1="70" x2="156" y2="70" stroke="#000" strokeOpacity="0.1" />
</svg>
);
}
if (kind === "clock") {
const steps = ["research", "outline", "write", "optimize", "links", "publish"];
return (
<div className={`${common} flex flex-wrap items-center justify-center gap-1.5 p-3`}>
{steps.map((s, i) => (
<Fragment key={s}>
<motion.span
initial={{ opacity: 0, y: 4 }}
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 4 }}
transition={{ delay: i * 0.06 }}
className="rounded-md border border-black/10 bg-white px-2 py-0.5 text-[10px] font-medium text-black/60 shadow-[0_1px_0_rgba(0,0,0,0.04)]"
>
{s}
</motion.span>
{i < steps.length - 1 && (
<span className="text-[10px] text-[color-mix(in_oklab,var(--brand)_60%,transparent)]">
→
</span>
)}
</Fragment>
))}
</div>
);
}
// race
return (
<svg viewBox="0 0 160 80" className={common} aria-hidden>
<line x1="0" y1="56" x2="160" y2="56" stroke="#000" strokeOpacity="0.08" strokeDasharray="3 3" />
<motion.g
initial={{ x: 0 }}
animate={inView ? { x: 110 } : { x: 0 }}
transition={{ duration: 1.4, ease: "easeOut" }}
>
<rect x="0" y="44" width="28" height="14" rx="3" fill="var(--brand)" />
<text x="14" y="54" textAnchor="middle" fontSize="8" fill="#fff" fontWeight="700">
them
</text>
</motion.g>
<motion.g
initial={{ x: 0 }}
animate={inView ? { x: 30 } : { x: 0 }}
transition={{ duration: 1.4, ease: "easeOut" }}
>
<rect x="0" y="62" width="28" height="14" rx="3" fill="#000" opacity="0.35" />
<text x="14" y="72" textAnchor="middle" fontSize="8" fill="#fff" fontWeight="700">
you
</text>
</motion.g>
</svg>
);
}
function PainCard({ pain, index }: { pain: Pain; index: number }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: 0.3 });
return (
<div className="h-full">
<div
ref={ref}
className="group relative flex h-full flex-col overflow-hidden rounded-2xl border border-transparent p-5 transition-all duration-300 hover:border-black/10 hover:shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
style={display}
className="text-[11px] tracking-widest text-[color-mix(in_oklab,var(--brand)_70%,transparent)]"
>
0{index + 1}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-[color-mix(in_oklab,var(--brand)_20%,transparent)] to-transparent" />
</div>
<p className="mt-3 text-[17px] leading-6 font-semibold text-black/90">{pain.title}</p>
<p className="mt-2 text-sm leading-6 text-black/55">{pain.subtext}</p>
</div>
<div className="relative mt-5 h-20 shrink-0 overflow-hidden rounded-xl bg-black/[0.02] ring-1 ring-black/5">
<MiniVisual kind={pain.visual} inView={inView} />
</div>
</div>
</div>
);
}
/**
* "The problem" section for a marketing page — a grid of pain-point cards, each
* with an animated mini-visual that plays on scroll, closing on a rotating
* "nothing compounds" finale. Self-contained theme (amber accent via `brand`,
* Pixelify display font loaded by the block); light-mode. Composes text-reveal.
*/
export function PainPoints({ brand = "#FF4701" }: { brand?: string }) {
const reduce = useReducedMotion();
return (
<section
className="relative overflow-hidden bg-[#FEFEF5] px-5 py-16 text-neutral-950 sm:px-8 sm:py-20"
style={{ ["--brand" as string]: brand }}
>
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400..700&display=swap"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 [background-image:radial-gradient(rgba(0,0,0,0.04)_1px,transparent_1px)] [background-size:22px_22px] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_75%)]"
/>
<div
aria-hidden
className="pointer-events-none absolute top-1/3 left-1/2 -z-10 h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-[color-mix(in_oklab,var(--brand)_10%,transparent)] blur-[120px]"
/>
<div className="relative mx-auto max-w-6xl">
<div className="max-w-3xl">
<TextReveal
as="h2"
style={display}
className="text-3xl leading-tight tracking-tight sm:text-4xl md:text-5xl"
>
SEO isn't hard.
<br />
<span className="text-black/45">It's just exhausting.</span>
</TextReveal>
<TextReveal delay={0.1} className="mt-3 text-base leading-7 text-black/65 sm:text-lg">
You're not losing because you're bad at SEO. You're losing because the
game changed — and the old playbook still demands all your time.
</TextReveal>
</div>
<div className="relative mt-10 grid grid-cols-1 *:border-b *:border-black/5 sm:grid-cols-2 [&>*:last-child]:border-b-0 sm:[&>*:nth-child(odd)]:border-r sm:[&>*:nth-child(odd)]:border-black/5 sm:[&>*:nth-last-child(-n+2)]:border-b-0">
{pains.map((pain, i) => (
<PainCard key={pain.title} pain={pain} index={i} />
))}
</div>
<div className="mt-4 flex justify-center" aria-hidden>
<div className="h-8 w-px bg-linear-to-b from-[color-mix(in_oklab,var(--brand)_40%,transparent)] to-transparent" />
</div>
<div className="relative mt-2 p-4 sm:p-6">
<div className="relative flex flex-col items-center gap-6 text-center">
<motion.span
animate={reduce ? undefined : { rotate: 360 }}
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-[var(--brand)] text-white shadow-[0_10px_30px_-10px_color-mix(in_oklab,var(--brand)_55%,transparent)] ring-1 ring-[color-mix(in_oklab,var(--brand)_20%,transparent)]"
>
<InfinityIcon size={26} />
</motion.span>
<div>
<span
style={display}
className="text-[11px] tracking-widest text-[var(--brand)]"
>
THE TRAP
</span>
<p
style={display}
className="mt-2 text-2xl leading-tight text-neutral-950 sm:text-3xl md:text-4xl"
>
So you stay busy — but nothing compounds.
</p>
<p className="mx-auto mt-3 max-w-xl text-base leading-7 text-black/60">
You're stuck in the cycle of doing SEO, but never seeing SEO work.
</p>
</div>
</div>
</div>
</div>
</section>
);
}