Sections

Features Rows

Three alternating copy-plus-visual feature rows, each revealing independently on scroll.

Install

npx shadcn@latest add @paragon/features-rows

features-rows.tsx

"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&apos;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>
  );
}