Three alternating copy-plus-visual feature rows, each revealing independently on scroll.
npx shadcn@latest add @paragon/features-rows"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
const REVEAL =
"transition-[opacity,translate,filter] duration-500 ease-out motion-reduce:transition-none";
const HIDDEN = "translate-y-3 opacity-0 blur-[4px]";
const SHOWN = "translate-y-0 opacity-100 blur-[0px]";
/* ---- Visual frames: mini product mockups built from real elements ---- */
const METER_EVENTS = [
{ name: "api.request", count: "1,204,882", unit: "calls" },
{ name: "storage.gb_hour", count: "88,410", unit: "GB·h", hot: true },
{ name: "seats.active", count: "1,842", unit: "seats" },
{ name: "export.rows", count: "412,097", unit: "rows" },
];
function MeteringVisual() {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between px-1 pb-1">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Live meters
</p>
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<span className="size-1.5 rounded-full bg-emerald-500" />
streaming
</span>
</div>
{METER_EVENTS.map((event) => (
<div
key={event.name}
className={cn(
"flex items-center justify-between rounded-lg border border-border px-3 py-2",
event.hot && "bg-muted/50",
)}
>
<span className="font-mono text-[11px] text-muted-foreground">
{event.name}
</span>
<span className="text-[11px] font-medium tabular-nums">
{event.count}
<span className="ml-1 font-normal text-muted-foreground">
{event.unit}
</span>
</span>
</div>
))}
</div>
);
}
const PRICE_TIERS = [
{ range: "First 1M calls", price: "$0.0008 / call" },
{ range: "1M – 10M calls", price: "$0.0005 / call" },
{ range: "Beyond 10M", price: "$0.0003 / call" },
];
function PricingVisual() {
return (
<div>
<div className="flex items-center justify-between px-1 pb-2">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Price model · API calls
</p>
<span className="rounded-md bg-primary px-2 py-0.5 text-[10px] font-medium text-primary-foreground">
Publish
</span>
</div>
<div className="divide-y divide-border rounded-lg border border-border">
{PRICE_TIERS.map((tier) => (
<div
key={tier.range}
className="flex items-center justify-between px-3 py-2.5"
>
<span className="text-[11px] text-muted-foreground">
{tier.range}
</span>
<span className="font-mono text-[11px] tabular-nums">
{tier.price}
</span>
</div>
))}
</div>
<p className="mt-2 px-1 text-[10px] text-muted-foreground">
Simulated on last month's usage: revenue +4.2%, churn impact none.
</p>
</div>
);
}
const INVOICE_LINES = [
{ label: "Platform fee", value: "$2,400.00" },
{ label: "API usage · 8.2M calls", value: "$5,140.00" },
{ label: "Storage · 12.4 TB·h", value: "$1,116.00" },
];
function RevenueVisual() {
return (
<div>
<div className="flex items-center justify-between px-1 pb-2">
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
Invoice · Northwind Labs
</p>
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
Reconciled
</span>
</div>
<div className="rounded-lg border border-border">
{INVOICE_LINES.map((line) => (
<div
key={line.label}
className="flex items-center justify-between border-b border-border px-3 py-2"
>
<span className="text-[11px] text-muted-foreground">
{line.label}
</span>
<span className="text-[11px] tabular-nums">{line.value}</span>
</div>
))}
<div className="flex items-center justify-between px-3 py-2.5">
<span className="text-[11px] font-medium">Total · Sept 2026</span>
<span className="text-[11px] font-semibold tabular-nums">
$8,656.00
</span>
</div>
</div>
</div>
);
}
/* ---- Rows ---- */
const ROWS = [
{
eyebrow: "Metering",
title: "Meter anything your product does",
body: "Send raw events from anywhere in your stack. Keel deduplicates, aggregates, and turns them into billable meters in real time — no nightly jobs, no double counting.",
visual: <MeteringVisual />,
},
{
eyebrow: "Pricing",
title: "Model prices without spreadsheets",
body: "Draft tiered, volume, or hybrid pricing and simulate it against real historical usage before you publish. See the revenue impact of every change first.",
visual: <PricingVisual />,
},
{
eyebrow: "Revenue recognition",
title: "Close the books in hours, not weeks",
body: "Every meter maps to a ledger account. Invoices, proration, and rev-rec schedules reconcile automatically, so finance stops re-deriving billing in spreadsheets.",
visual: <RevenueVisual />,
},
];
function FeatureRow({
row,
index,
}: {
row: (typeof ROWS)[number];
index: number;
}) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, margin: "-80px" });
const reduced = useReducedMotion();
const revealed = inView || !!reduced;
const flipped = index % 2 === 1;
return (
<div
ref={ref}
className="grid items-center gap-8 lg:grid-cols-2 lg:gap-16"
>
<div
className={cn(flipped && "lg:order-last", REVEAL, revealed ? SHOWN : HIDDEN)}
>
<p className="text-sm font-medium text-muted-foreground">
{row.eyebrow}
</p>
<h3 className="mt-2 text-2xl font-semibold tracking-tight text-balance">
{row.title}
</h3>
<p className="mt-3 max-w-md text-base text-pretty text-muted-foreground">
{row.body}
</p>
<a
href="#"
className="mt-4 inline-flex items-center gap-1 text-sm font-medium transition-[color] duration-150 ease-out hover:text-muted-foreground"
>
Learn more
<span aria-hidden>→</span>
</a>
</div>
<div
aria-hidden
className={cn(
"pointer-events-none rounded-xl bg-card p-4 shadow-border select-none",
REVEAL,
revealed ? SHOWN : HIDDEN,
)}
style={{ transitionDelay: "80ms" }}
>
{row.visual}
</div>
</div>
);
}
export interface FeaturesRowsProps extends React.ComponentProps<"section"> {
eyebrow?: string;
headline?: string;
}
/**
* Three alternating feature rows — copy beside a bordered visual frame,
* sides swapping each row. Each row reveals independently as it scrolls
* into view (first time only).
*/
export function FeaturesRows({
eyebrow = "How it works",
headline = "Usage-based billing, end to end",
className,
...props
}: FeaturesRowsProps) {
const headerRef = React.useRef<HTMLDivElement>(null);
const headerInView = useInView(headerRef, { once: true, margin: "-80px" });
const reduced = useReducedMotion();
const headerRevealed = headerInView || !!reduced;
return (
<section
aria-labelledby="features-rows-headline"
className={cn("w-full bg-background px-4 py-16 sm:px-6 md:py-24", className)}
{...props}
>
<div className="mx-auto w-full max-w-5xl">
<div
ref={headerRef}
className={cn(
"max-w-2xl",
REVEAL,
headerRevealed ? SHOWN : HIDDEN,
)}
>
<p className="text-sm font-medium text-muted-foreground">{eyebrow}</p>
<h2
id="features-rows-headline"
className="mt-2 text-3xl font-semibold tracking-tight text-balance sm:text-4xl"
>
{headline}
</h2>
</div>
<div className="mt-14 space-y-16 md:space-y-24">
{ROWS.map((row, i) => (
<FeatureRow key={row.eyebrow} row={row} index={i} />
))}
</div>
</div>
</section>
);
}