# Feature Comparison Table

> Interactive pricing comparison table with animated checkmarks and plan highlights (Base UI)

A shadcn/ui block from the [@uitripled](https://ui.tripled.work) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uitripled/feature-comparison-block-baseui).

- Page, with a live preview: https://shadcn.exchange/r/uitripled/feature-comparison-block-baseui
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uitripled/feature-comparison-block-baseui-light-9f319167ee77f80c.png
- Registry item: https://ui.tripled.work/r/feature-comparison-block-baseui.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.tripled.work/r/feature-comparison-block-baseui.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`, `react`
- Registry items: `button`

## Files

### `@uitripled/react-baseui/src/components/sections/feature-comparison-block-baseui.tsx`

```tsx
"use client";

import { Button } from "@base-ui/react/button";
import { motion, useReducedMotion } from "framer-motion";
import { ArrowRight, Check, Crown, Rocket, Sparkles, Star, X, Zap } from "lucide-react";
import { Fragment, useId, useState } from "react";

const cn = (...c: (string | false | undefined)[]) => c.filter(Boolean).join(" ");

type PlanKey = "free" | "pro" | "enterprise";

const plans = [
  {
    key: "free" as PlanKey,
    name: "Free",
    popular: false,
    price: "$0",
    period: "/month",
    description: "Perfect for getting started",
    icon: Rocket,
    features: ["5 projects", "Basic components", "Community support"],
    cta: "Choose plan",
  },
  {
    key: "pro" as PlanKey,
    name: "Pro",
    popular: true,
    price: "$29",
    period: "/month",
    description: "Most popular for professionals",
    icon: Star,
    features: [
      "Unlimited projects",
      "Advanced animations",
      "Priority support",
      "API access",
    ],
    cta: "Get started",
  },
  {
    key: "enterprise" as PlanKey,
    name: "Enterprise",
    popular: false,
    price: "$99",
    period: "/month",
    description: "For large teams and organizations",
    icon: Crown,
    features: [
      "Everything in Pro",
      "Custom integrations",
      "Dedicated manager",
      "SLA support",
    ],
    cta: "Contact sales",
  },
];

const features: {
  category: string;
  items: { name: string; free: boolean; pro: boolean; enterprise: boolean }[];
}[] = [
  {
    category: "Core features",
    items: [
      { name: "Basic components", free: true, pro: true, enterprise: true },
      { name: "Advanced animations", free: false, pro: true, enterprise: true },
      { name: "Custom themes", free: false, pro: true, enterprise: true },
      { name: "Priority support", free: false, pro: false, enterprise: true },
    ],
  },
  {
    category: "Integrations",
    items: [
      { name: "API access", free: false, pro: true, enterprise: true },
      { name: "Webhooks", free: false, pro: false, enterprise: true },
      { name: "Custom integrations", free: false, pro: false, enterprise: true },
    ],
  },
  {
    category: "Support and limits",
    items: [
      { name: "5 projects", free: true, pro: false, enterprise: false },
      { name: "Unlimited projects", free: false, pro: true, enterprise: true },
      { name: "Team collaboration", free: false, pro: true, enterprise: true },
      { name: "Dedicated manager", free: false, pro: false, enterprise: true },
    ],
  },
];

/**
 * Availability is carried by an icon plus text, never by the icon alone — the
 * whole table is unreadable otherwise once you cannot see the glyph shapes.
 */
function Availability({ included }: { included: boolean }) {
  return (
    <>
      {included ? (
        <Check className="mx-auto size-5 text-foreground" aria-hidden />
      ) : (
        <X className="mx-auto size-5 text-muted-foreground" aria-hidden />
      )}
      <span className="sr-only">{included ? "Included" : "Not included"}</span>
    </>
  );
}

export function FeatureComparisonBlockBaseui() {
  const [hoveredPlan, setHoveredPlan] = useState<number | null>(null);
  const shouldReduceMotion = useReducedMotion();
  const tableId = useId();

  const reveal = (delay: number) =>
    shouldReduceMotion
      ? { initial: { opacity: 1, y: 0 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0 } }
      : {
          initial: { opacity: 0, y: 20 },
          animate: { opacity: 1, y: 0 },
          transition: { duration: 0.5, delay },
        };

  return (
    <section className="w-full bg-background px-4 py-16">
      <div className="mx-auto max-w-7xl">
        <motion.div {...reveal(0)} className="mb-12 text-center">
          <span className="mb-4 inline-flex items-center rounded-full border border-transparent bg-secondary px-2.5 py-0.5 text-xs font-semibold text-secondary-foreground">
            <Zap className="mr-1 size-3" aria-hidden />
            Compare plans
          </span>
          <h2 className="mb-4 text-balance text-4xl font-bold tracking-tight">
            Choose the right plan for you
          </h2>
          <p className="mx-auto max-w-2xl text-pretty text-muted-foreground">
            Compare features across all our plans and find the perfect fit for
            your needs.
          </p>
        </motion.div>

        {/* Pricing cards */}
        <div className="mb-12 grid grid-cols-1 gap-6 lg:grid-cols-3">
          {plans.map((plan, index) => {
            const Icon = plan.icon;
            const isHovered = hoveredPlan === index;

            return (
              <motion.div
                key={plan.name}
                {...reveal(0.1 * index)}
                onHoverStart={() => setHoveredPlan(index)}
                onHoverEnd={() => setHoveredPlan(null)}
                className="relative flex"
              >
                <div
                  className={cn(
                    "group relative flex w-full flex-col overflow-hidden rounded-lg border bg-card p-6 text-card-foreground shadow-sm md:p-8",
                    "transition-[border-color,box-shadow] duration-300 ease-[cubic-bezier(0.2,0,0,1)]",
                    "motion-reduce:transition-none",
                    plan.popular
                      ? "border-foreground shadow-lg"
                      : "border-border hover:border-foreground/40 hover:shadow-md"
                  )}
                >
                  {plan.popular && (
                    <div className="absolute right-0 top-0">
                      <span className="inline-flex items-center gap-1 rounded-bl-lg rounded-tr-xl bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground">
                        <Sparkles className="size-3" aria-hidden />
                        Most popular
                      </span>
                    </div>
                  )}

                  <div className="mb-4 flex justify-center">
                    <div className="rounded-2xl bg-muted p-3 text-foreground">
                      <Icon className="size-8" strokeWidth={1.5} aria-hidden />
                    </div>
                  </div>

                  <div className="mb-6 text-center">
                    <h3 className="mb-2 text-2xl font-bold tracking-tight">
                      {plan.name}
                    </h3>
                    <p className="text-pretty text-sm text-muted-foreground">
                      {plan.description}
                    </p>
                  </div>

                  <div className="mb-6 text-center">
                    <span className="text-5xl font-bold tracking-tight tabular-nums">
                      {plan.price}
                    </span>
                    <span className="ml-1 text-base text-muted-foreground">
                      {plan.period}
                    </span>
                  </div>

                  <ul className="mb-6 space-y-3">
                    {plan.features.map((feature) => (
                      <li key={feature} className="flex items-start gap-2">
                        <Check
                          className="mt-0.5 size-5 shrink-0 text-foreground"
                          aria-hidden
                        />
                        <span className="text-sm text-foreground">{feature}</span>
                      </li>
                    ))}
                  </ul>

                  <Button
                    className={cn(
                      "mt-auto inline-flex h-11 w-full items-center justify-center gap-2 rounded-md px-8 text-sm font-medium transition-colors",
                      "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
                      plan.popular
                        ? "bg-primary text-primary-foreground hover:bg-primary/90"
                        : "border border-input bg-background hover:bg-accent hover:text-accent-foreground"
                    )}
                  >
                    {plan.cta}
                    <ArrowRight
                      className={cn(
                        "size-4 transition-transform duration-200",
                        isHovered && "translate-x-0.5",
                        "motion-reduce:transition-none motion-reduce:translate-x-0"
                      )}
                      aria-hidden
                    />
                  </Button>
                </div>
              </motion.div>
            );
          })}
        </div>

        {/*
          A real table: without column headers the ticks below are three
          anonymous columns, and screen readers announce the row with no way to
          tell which plan each mark belongs to. The wrapper is focusable so the
          horizontal scroll is reachable by keyboard.
        */}
        <div
          role="region"
          aria-labelledby={`${tableId}-caption`}
          tabIndex={0}
          className="overflow-x-auto rounded-lg border border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <table className="w-full min-w-[560px] border-collapse text-sm">
            <caption id={`${tableId}-caption`} className="sr-only">
              Feature availability across the Free, Pro and Enterprise plans
            </caption>
            <thead>
              <tr className="border-b border-border bg-muted/50">
                <th
                  scope="col"
                  className="sticky left-0 z-10 bg-muted/50 p-4 text-left font-semibold"
                >
                  Feature
                </th>
                {plans.map((plan) => (
                  <th
                    key={plan.key}
                    scope="col"
                    className="p-4 text-center font-semibold"
                  >
                    {plan.name}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {features.map((category) => (
                <Fragment key={category.category}>
                  <tr className="border-b border-border">
                    <th
                      scope="colgroup"
                      colSpan={plans.length + 1}
                      className="bg-background p-4 text-left text-base font-semibold"
                    >
                      {category.category}
                    </th>
                  </tr>
                  {category.items.map((item) => (
                    <tr
                      key={item.name}
                      className="border-b border-border last:border-b-0"
                    >
                      <th
                        scope="row"
                        className="sticky left-0 z-10 bg-card p-4 text-left font-medium"
                      >
                        {item.name}
                      </th>
                      {plans.map((plan) => (
                        <td key={plan.key} className="p-4 text-center">
                          <Availability included={item[plan.key]} />
                        </td>
                      ))}
                    </tr>
                  ))}
                </Fragment>
              ))}
            </tbody>
          </table>
        </div>

        {/* Kept outside the scroll container, so it is never pushed off-screen */}
        <motion.div {...reveal(0.4)} className="mt-12 text-center">
          <p className="mb-4 text-sm text-muted-foreground">
            Need a custom plan?
          </p>
          <Button className="inline-flex h-11 items-center justify-center rounded-md border border-input bg-background px-8 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
            Contact sales
          </Button>
        </motion.div>
      </div>
    </section>
  );
}
```
