Skip to content

Plans against features with a sticky header, a gliding row highlight and phone view.

Marketingmotion@base-ui/react

01Preview

Compare Northwind plans
Free

$0forever

ProPopular

$16per seat / mo

Business

$32per seat / mo

Usage
Projects
3
Unlimited
Unlimited
File storage
2 GB
100 GB
1 TBThen $4 per 100 GB
Not included
IncludedUp to 10
Included
Collaboration
Comments and mentions
Included
Included
Included
7 days
90 days
Unlimited
Shared templates
Not included
Included
Included
Security
Two-factor enforcement
Not included
Included
Included
Not included
Not included
Included
Audit log
Not included
Not included
IncludedExportable
Support
Response time
Community
1 business day
4 hours

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @base-ui/react

03Usage

import { ComparisonTable } from "@/components/ui/comparison-table";

<ComparisonTable
  highlight="pro"
  stickyTop={56}
  plans={[
    { id: "free", name: "Free", price: "$0", period: "forever", action: { label: "Start free", href: "/signup" } },
    { id: "pro", name: "Pro", price: "$16", period: "per seat / mo", badge: "Popular", action: { label: "Start trial", href: "/signup?plan=pro" } },
  ]}
  sections={[
    {
      title: "Usage",
      features: [
        { name: "Projects", values: { free: "3", pro: "Unlimited" } },
        { name: "Guests", description: "People outside your workspace who can comment.", values: { free: false, pro: { value: true, note: "Up to 10" } } },
      ],
    },
  ]}
/>

04Source

"use client";
import { Popover } from "@base-ui/react/popover";
import { Toggle } from "@base-ui/react/toggle";
import { ToggleGroup } from "@base-ui/react/toggle-group";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/cn";
import { Check, X } from "@/lib/icons";
import { spring } from "@/lib/motion";
import { useControllableState } from "@/lib/use-controllable-state";

export type ComparisonPlan = {
  id: string;
  name: string;
  /** "$20", "Free", "Custom". */
  price?: React.ReactNode;
  /** Quiet text after the price: "/ month", "per seat". */
  period?: React.ReactNode;
  /** A short label beside the name, usually on the highlighted plan: "Popular". */
  badge?: string;
  /** The plan's call to action. Gets an href to render a link, or onClick for a button. */
  action?: { label: string; href?: string; onClick?: () => void };
};

/** true draws a check, false a cross, a string is printed. Add a note for "up to 3", "add-on", etc. */
export type ComparisonValue = boolean | string | { value: boolean | string; note?: string };

export type ComparisonFeature = {
  name: string;
  /** Opens in a small popover from the feature name, on hover or press. */
  description?: string;
  values: Record<string, ComparisonValue>;
};

export type ComparisonSection = { title: string; features: ComparisonFeature[] };

export type ComparisonTableProps = Omit<React.ComponentProps<"div">, "children"> & {
  plans: ComparisonPlan[];
  sections: ComparisonSection[];
  /** Id of the plan whose column is outlined and whose action is primary. */
  highlight?: string;
  /** Distance from the top of the scrolling area where the plan header sticks (a sticky site header's height). */
  stickyTop?: number | string;
  /** Names the table for screen readers. */
  caption?: string;
  /** Content for the empty cell above the feature names. */
  corner?: React.ReactNode;
  /** On narrow containers one plan shows at a time. Controlled plan id… */
  plan?: string;
  /** …or the one shown first. Defaults to the highlighted plan. */
  defaultPlan?: string;
  onPlanChange?: (id: string) => void;
};

// The highlighted column is drawn with inset shadows rather than borders, so it
// can have side rules, a rounded cap and a 3% tint without shifting any cell.
const col = {
  side: "shadow-[inset_1px_0_0_var(--line-2),inset_-1px_0_0_var(--line-2),inset_0_0_0_100vmax_color-mix(in_oklab,var(--fg)_3%,transparent)]",
  top: "shadow-[inset_1px_0_0_var(--line-2),inset_-1px_0_0_var(--line-2),inset_0_1px_0_var(--line-2),inset_0_0_0_100vmax_color-mix(in_oklab,var(--fg)_3%,transparent)]",
  bottom: "shadow-[inset_1px_0_0_var(--line-2),inset_-1px_0_0_var(--line-2),inset_0_-1px_0_var(--line-2),inset_0_0_0_100vmax_color-mix(in_oklab,var(--fg)_3%,transparent)]",
};

/**
 * Plans across the top, features down the side. The plan header sticks while
 * the features scroll under it, one highlight glides with the pointer so a row
 * can be read across, and on a narrow container the columns collapse to one
 * plan at a time behind a switcher.
 */
export function ComparisonTable({
  plans,
  sections,
  highlight,
  stickyTop = 0,
  caption = "Compare plans",
  corner,
  plan: planProp,
  defaultPlan,
  onPlanChange,
  className,
  style,
  ...rest
}: ComparisonTableProps) {
  const reduce = useReducedMotion();
  const [active, setActive] = useControllableState({
    value: planProp,
    defaultValue: defaultPlan ?? highlight ?? plans[0]?.id ?? "",
    onChange: onPlanChange,
  });
  const root = useRef<HTMLDivElement>(null);
  const table = useRef<HTMLTableElement>(null);
  const head = useRef<HTMLTableSectionElement>(null);
  const [stuck, setStuck] = useState(false);
  const [row, setRow] = useState<{ y: number; h: number; shown: boolean; jump: boolean }>({ y: 0, h: 0, shown: false, jump: true });
  const captionId = useId();

  // The header is stuck when it has slid down from the table's top edge. Any
  // scroll, of the window or of a container, is checked once per frame.
  useEffect(() => {
    let frame = 0;
    const check = () => {
      frame = 0;
      const t = table.current?.getBoundingClientRect();
      const h = head.current?.getBoundingClientRect();
      if (!t || !h) return;
      const next = h.top - t.top > 1;
      setStuck((s) => (s === next ? s : next));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(check);
    };
    window.addEventListener("scroll", onScroll, { capture: true, passive: true });
    window.addEventListener("resize", onScroll, { passive: true });
    onScroll();
    return () => {
      window.removeEventListener("scroll", onScroll, { capture: true });
      window.removeEventListener("resize", onScroll);
      cancelAnimationFrame(frame);
    };
  }, []);

  const track = (e: React.PointerEvent<HTMLTableRowElement>) => {
    if (e.pointerType !== "mouse" || !root.current) return;
    const r = e.currentTarget.getBoundingClientRect();
    const o = root.current.getBoundingClientRect();
    setRow((prev) => ({ y: r.top - o.top, h: r.height, shown: true, jump: !prev.shown }));
  };

  const top = typeof stickyTop === "number" ? `${stickyTop}px` : stickyTop;
  const last = (s: number, f: number) => s === sections.length - 1 && f === sections[s].features.length - 1;

  return (
    <div
      ref={root}
      data-slot="comparison-table"
      data-stuck={stuck || undefined}
      className={cn("group/ct @container relative w-full min-w-0 [--ct-bg:var(--frame)]", className)}
      style={{ ...style, ["--ct-top" as string]: top }}
      {...rest}
    >
      <PlanSwitcher plans={plans} active={active} onChange={setActive} reduce={!!reduce} label={caption} />

      <table
        ref={table}
        aria-labelledby={captionId}
        // Leaving the table fades the highlight; moving between rows only moves it.
        onPointerLeave={() => setRow((r) => ({ ...r, shown: false }))}
        className="w-full table-fixed border-separate border-spacing-0 text-[13px]">
        <caption id={captionId} className="sr-only">
          {caption}
        </caption>
        <thead ref={head}>
          <tr>
            <td
              className={cn(
                "sticky top-(--ct-top) z-(--z-sticky) w-[34%] bg-(--ct-bg) @max-[36rem]:top-[calc(var(--ct-top)+58px)] pb-3 pr-4 align-bottom @max-[36rem]:w-[52%]",
                "border-b border-line transition-[border-color] duration-200 group-data-stuck/ct:border-line-2",
                "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-3 after:bg-linear-to-b after:from-(--ct-bg) after:to-transparent after:opacity-0 after:transition-opacity after:duration-200 after:content-[''] group-data-stuck/ct:after:opacity-100",
              )}
            >
              {corner}
            </td>
            {plans.map((p) => {
              const hi = p.id === highlight;
              return (
                <th
                  key={p.id}
                  scope="col"
                  data-plan={p.id}
                  data-highlight={hi || undefined}
                  className={cn(
                    "sticky top-(--ct-top) z-(--z-sticky) bg-(--ct-bg) p-0 @max-[36rem]:top-[calc(var(--ct-top)+58px)] text-left align-bottom font-normal",
                    "border-b border-line transition-[border-color] duration-200 group-data-stuck/ct:border-line-2",
                "after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-3 after:bg-linear-to-b after:from-(--ct-bg) after:to-transparent after:opacity-0 after:transition-opacity after:duration-200 after:content-[''] group-data-stuck/ct:after:opacity-100",
                    // Narrow containers show one plan column at a time.
                    p.id !== active && "@max-[36rem]:hidden",
                    hi && cn("rounded-t-xl", col.top),
                  )}
                >
                  <PlanHead plan={p} highlighted={hi} />
                </th>
              );
            })}
          </tr>
        </thead>

        {sections.map((section, si) => (
          <tbody key={section.title}>
            <tr>
              <th scope="colgroup" colSpan={1} className="pb-2 pr-4 pt-6 text-left align-bottom font-normal">
                <span className="font-mono text-2xs uppercase tracking-[0.08em] text-fg-3">{section.title}</span>
              </th>
              {plans.map((p) => (
                <td
                  key={p.id}
                  aria-hidden
                  className={cn(p.id !== active && "@max-[36rem]:hidden", p.id === highlight && col.side)}
                />
              ))}
            </tr>
            {section.features.map((f, fi) => (
              <tr key={f.name} onPointerEnter={track}>
                <th scope="row" className="border-t border-line py-2.5 pr-4 text-left align-top font-normal text-fg">
                  <FeatureName feature={f} />
                </th>
                {plans.map((p) => {
                  const hi = p.id === highlight;
                  const end = last(si, fi);
                  return (
                    <td
                      key={p.id}
                      data-plan={p.id}
                      className={cn(
                        "border-t border-line px-3 py-2.5 align-top",
                        p.id !== active && "@max-[36rem]:hidden",
                        hi && (end ? cn("rounded-b-xl", col.bottom) : col.side),
                        hi && end && "pb-3.5",
                      )}
                    >
                      <Cell value={f.values[p.id]} />
                    </td>
                  );
                })}
              </tr>
            ))}
          </tbody>
        ))}
      </table>

      {/* One highlight for every row: it glides after the pointer so a line can be read across the plans. */}
      <motion.div
        aria-hidden
        className="pointer-events-none absolute inset-x-0 top-0 rounded-md bg-fg/[0.035] motion-reduce:hidden"
        initial={false}
        animate={{ y: row.y, height: row.h, opacity: row.shown ? 1 : 0 }}
        transition={
          reduce
            ? { duration: 0 }
            : { y: row.jump ? { duration: 0 } : spring.follow, height: row.jump ? { duration: 0 } : spring.follow, opacity: { duration: row.shown ? 0.12 : 0.15 } }
        }
      />
    </div>
  );
}

function PlanHead({ plan, highlighted }: { plan: ComparisonPlan; highlighted: boolean }) {
  const a = plan.action;
  const actionClass = cn(
    "relative mt-1 inline-flex h-7 w-full select-none items-center justify-center rounded-md px-2.5 text-[12px] font-medium tracking-[-0.005em]",
    "outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3",
    "transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.97] active:duration-75",
    "before:absolute before:-inset-y-2 before:inset-x-0 before:content-[''] pointer-fine:before:hidden",
    highlighted ? "bg-fg text-frame hover:bg-fg/90" : "border border-line-2 bg-raised text-fg shadow-[var(--shadow)] hover:border-fg-4 hover:bg-hover",
  );
  return (
    <div className="flex min-w-0 flex-col gap-1 px-3 pb-3 pt-3.5">
      <div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1">
        <span className="truncate text-[13.5px] font-medium tracking-[-0.01em] text-fg">{plan.name}</span>
        {plan.badge && (
          <span className="inline-flex h-[18px] shrink-0 items-center rounded-full border border-line-2 bg-raised px-1.5 text-[10.5px] font-medium leading-none text-fg-2">
            {plan.badge}
          </span>
        )}
      </div>
      {plan.price != null && (
        <p className="flex min-w-0 flex-wrap items-baseline gap-x-1">
          <span className="text-[18px] font-medium leading-6 tracking-[-0.02em] text-fg tabular">{plan.price}</span>
          {plan.period != null && <span className="text-[11.5px] text-fg-3">{plan.period}</span>}
        </p>
      )}
      {a &&
        (a.href ? (
          <a href={a.href} onClick={a.onClick} className={cn(actionClass, "cursor-pointer")}>
            {a.label}
          </a>
        ) : (
          <button type="button" onClick={a.onClick} className={actionClass}>
            {a.label}
          </button>
        ))}
    </div>
  );
}

function FeatureName({ feature }: { feature: ComparisonFeature }) {
  if (!feature.description) return <span className="text-pretty">{feature.name}</span>;
  return (
    <Popover.Root>
      <Popover.Trigger
        openOnHover
        delay={300}
        closeDelay={80}
        className={cn(
          "-mx-1 cursor-help rounded-[4px] px-1 text-left text-pretty text-fg outline-none",
          "underline decoration-fg-4 decoration-dotted underline-offset-[4px] transition-[text-decoration-color] duration-150",
          "hover:decoration-fg-2 data-popup-open:decoration-fg-2",
          "focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3",
        )}
      >
        {feature.name}
      </Popover.Trigger>
      <Popover.Portal>
        <Popover.Positioner side="top" align="start" sideOffset={8} collisionPadding={8} className="z-(--z-popover)">
          <Popover.Popup
            className={cn(
              "w-64 max-w-(--available-width) rounded-xl border border-line-2 bg-raised px-3 py-2.5 text-fg shadow-pop outline-none",
              "origin-(--transform-origin) transition-[opacity,scale,translate,filter] duration-200 ease-out-expo",
              "data-starting-style:scale-96 data-starting-style:opacity-0 data-starting-style:blur-[2px]",
              "data-[side=top]:data-starting-style:translate-y-1 data-[side=bottom]:data-starting-style:-translate-y-1",
              "data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-140 data-ending-style:ease-out-quart",
              "data-instant:transition-none",
              "motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100",
            )}
          >
            <Popover.Title className="text-[12.5px] font-medium tracking-[-0.005em]">{feature.name}</Popover.Title>
            <Popover.Description className="mt-0.5 text-pretty text-[12.5px] leading-[1.5] text-fg-2">{feature.description}</Popover.Description>
          </Popover.Popup>
        </Popover.Positioner>
      </Popover.Portal>
    </Popover.Root>
  );
}

function Cell({ value }: { value: ComparisonValue | undefined }) {
  const v = typeof value === "object" && value !== null ? value.value : value;
  const note = typeof value === "object" && value !== null ? value.note : undefined;
  return (
    <div className="flex min-w-0 flex-col gap-0.5">
      {v === true ? (
        <span className="flex h-5 items-center text-fg">
          <Check size={16} />
          <span className="sr-only">Included</span>
        </span>
      ) : v === false || v === undefined ? (
        <span className="flex h-5 items-center text-fg-4">
          <X size={14} />
          <span className="sr-only">Not included</span>
        </span>
      ) : (
        <span className="text-pretty leading-5 text-fg-2 tabular">{v}</span>
      )}
      {note && <span className="text-pretty text-[12px] leading-4 text-fg-3">{note}</span>}
    </div>
  );
}

/**
 * Below 36rem the table shows one plan; this picks which. One pill slides
 * between the plans on the snappy spring.
 */
function PlanSwitcher({
  plans,
  active,
  onChange,
  reduce,
  label,
}: {
  plans: ComparisonPlan[];
  active: string;
  onChange: (id: string) => void;
  reduce: boolean;
  label: string;
}) {
  const id = useId();
  return (
    // Sticks above the plan header, so the plan can be changed from anywhere in the table.
    <div className="sticky top-(--ct-top) z-(--z-sticky) hidden bg-(--ct-bg) pb-3 pt-2 @max-[36rem]:block">
    <ToggleGroup
      value={[active]}
      // A plan is always shown: pressing the current one again does nothing.
      onValueChange={(v) => v[0] && onChange(v[0])}
      aria-label={`${label}: choose a plan`}
      className="flex w-full rounded-lg border border-line bg-raised p-0.5"
    >
      {plans.map((p) => (
          <Toggle
            key={p.id}
            value={p.id}
            className={cn(
              "relative isolate flex h-8 min-w-0 flex-1 select-none items-center justify-center rounded-md px-2 text-[12.5px] font-medium text-fg-3",
              "outline-none transition-[color,scale] duration-150 hover:text-fg-2 active:scale-[0.97] data-pressed:text-fg",
              "focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3",
            )}
          >
            {p.id === active && (
              <motion.span
                layoutId={`${id}-pill`}
                className="absolute inset-0 -z-10 rounded-md border border-line-2 bg-frame"
                transition={reduce ? { duration: 0 } : spring.snappy}
              />
            )}
            <span className="truncate">{p.name}</span>
          </Toggle>
      ))}
    </ToggleGroup>
    </div>
  );
}

05Props

ComparisonTable

PropTypeDefaultDescription
plans*ComparisonPlan[]Columns, in order: { id, name, price?, period?, badge?, action?: { label, href?, onClick? } }.
sections*ComparisonSection[]Groups of rows: { title, features: { name, description?, values: Record<planId, ComparisonValue> }[] }.
highlightstringPlan id whose column is outlined and tinted and whose action is the primary button.
stickyTopnumber | string0Where the plan header sticks: the height of a sticky site header above it.
captionstring"Compare plans"The table’s accessible name.
cornerReactNodeContent for the empty cell above the feature names, like a billing toggle.
planstringControlled plan shown on narrow containers.
defaultPlanstringhighlightPlan shown first on narrow containers.
onPlanChange(id: string) => voidCalled when the phone switcher changes plan.

ComparisonValue

PropTypeDefaultDescription
truebooleanA check, read as “Included”.
falsebooleanA faint cross, read as “Not included”. A missing value means the same.
stringstringPrinted as is: “100 GB”, “Unlimited”, “4 hours”.
{ value, note }{ value: boolean | string; note?: string }Either of the above with a quiet line under it: “Up to 10”, “Exportable”.

06Notes

Behavior

  • The plan header sticks at stickyTop in the window or any scrolling parent; once it is stuck its rule steps up and a short fade appears under it, so rows slide beneath instead of cutting off.
  • Below a 36rem container (not viewport) the table shows one plan beside the features, picked from a switcher that sticks above the plan header, so the plan can be changed from any row.
  • The highlighted column is drawn with inset shadows, so its outline, rounded cap and 3% tint never shift a cell; notes and long feature names wrap rather than truncate.
  • Feature descriptions open from the name on hover after 300ms or on press, so they work on touch and from the keyboard.

Motion

  • One highlight follows the pointer across rows on the follow spring (650/45) so a line can be read across the plans; it appears in place on first entry and fades out over 150ms when the pointer leaves. Mouse only.
  • The phone switcher’s pill slides between plans on the snappy spring. Descriptions grow from the feature name: 200ms scale from 0.96 with 4px travel and a 2px blur, closing in 140ms.
  • Reduced motion hides the row highlight, sets the pill in place, and keeps the popover to an opacity fade.

Accessibility

  • A real table: a caption, plan names as column headers, feature names as row headers and section titles as colgroup headers, so every cell is read with its plan and feature.
  • Checks and crosses carry hidden “Included” and “Not included” text; color is never the only signal.
  • Plan actions are links when given an href. The phone switcher is a toggle group: arrows move between plans, Space or Enter picks one, and one plan is always shown.
  • Descriptions are Base UI popovers: Enter opens, Escape closes and returns focus to the feature name.