Feature Comparison
Choose two options, put their details side by side, and focus on what makes them different.
YOUR TEAM. YOUR PACE.
One team. A shared space.
One shared home for your projects, notes, and next steps. Find the right amount of room for the way you work.
03 options. One that feels right.Put the details side by side.
| Feature | IndependentA focused space for your own practice. $12/ person / month | A little more room StudioBring the whole team into the picture. $24/ person / month |
|---|---|---|
| Make room for the work | ||
| Active projects | 10 projects | Unlimited |
| File storageRoom for the details that make the difference. | 25 GB | 250 GB |
| Notes & documents | Included | Included |
| Better, together | ||
| Client guests | 3 guests | 25 guests |
| Review & approval | Not included | Included |
| Peace of mind | ||
| Version history | 30 days | 1 year |
| Export your work | Included | Included |
| Single sign-on | Not included | Not included |
| Meet Independent | Meet Studio | |
8 of 8 features shown Comparing Independent and Studio
FIND YOUR NEXT CHAPTER
For your own practice.
Independent keeps project notes, files, and a small circle of collaborators together. Designed for freelancers and solo studios.
For the work you share.
Studio gives your team room to collaborate with clients, review work, and revisit earlier decisions. Built for a growing creative practice.
For the bigger picture.
Collective brings larger teams together with more storage, unlimited guests, and single sign-on. Built for a shared way of working.
'use client';
import { useId, useState, type ReactNode } from 'react';
import './feature-comparison.css';
export type FeatureComparisonProduct = {
id: string;
name: string;
description?: string;
price?: string;
priceNote?: string;
badge?: string;
href?: string;
actionLabel?: string;
};
export type FeatureComparisonRow = {
id: string;
label: string;
description?: string;
group?: string;
values: Record<string, string | boolean | null>;
};
export type FeatureComparisonSelection = {
first: FeatureComparisonProduct;
second: FeatureComparisonProduct;
};
export type FeatureComparisonProps = {
products: FeatureComparisonProduct[];
features: FeatureComparisonRow[];
title?: string;
description?: string;
eyebrow?: string;
defaultProductIds?: readonly [string, string];
onSelectionChange?: (selection: FeatureComparisonSelection) => void;
className?: string;
};
/** Compare two distinct options, with readable values and an optional differences filter. */
export function FeatureComparison({ products, features, title = 'Find your fit.', description, eyebrow = 'A CLOSER LOOK', defaultProductIds, onSelectionChange, className = '' }: FeatureComparisonProps) {
const uid = useId();
const [selection, setSelection] = useState<readonly [string, string]>(() => defaultProductIds ?? [products[0]?.id ?? '', products[1]?.id ?? '']);
const [differencesOnly, setDifferencesOnly] = useState(false);
const first = products.find(product => product.id === selection[0]) ?? products[0];
const second = products.find(product => product.id === selection[1] && product.id !== first?.id) ?? products.find(product => product.id !== first?.id);
if (!first || !second) return null;
const differingRows = features.filter(row => (row.values[first.id] ?? null) !== (row.values[second.id] ?? null));
const rows = differencesOnly ? differingRows : features;
const groups = [...new Set(rows.map(row => row.group ?? 'Details'))];
function selectProduct(position: 0 | 1, id: string) {
const next = products.find(product => product.id === id);
if (!next || !first || !second) return;
const nextFirst = position === 0 ? next : first;
const nextSecond = position === 1 ? next : second;
if (nextFirst.id === nextSecond.id) return;
setSelection([nextFirst.id, nextSecond.id]);
onSelectionChange?.({ first: nextFirst, second: nextSecond });
}
function valueContent(value: string | boolean | null | undefined) {
if (typeof value === 'boolean') return <span className="ac-compare-boolean" data-included={value}><span aria-hidden="true">{value ? '✓' : '—'}</span><span>{value ? 'Included' : 'Not included'}</span></span>;
return value ?? <><span aria-hidden="true">—</span><span className="ac-compare-sr">Not specified</span></>;
}
return <section className={`ac-feature-comparison ${className}`.trim()} aria-labelledby={`${uid}-title`}>
<header className="ac-compare-intro"><div><p className="ac-compare-eyebrow"><span aria-hidden="true"/>{eyebrow}</p><h2 id={`${uid}-title`}>{title}</h2></div><div className="ac-compare-intro-note">{description && <p>{description}</p>}<span className="ac-compare-count"><b>{String(products.length).padStart(2, '0')}</b> options. One that feels right.</span></div></header>
<div className="ac-compare-controls"><p>Put the details side by side.<span aria-hidden="true">↘</span></p><label className="ac-compare-filter"><input type="checkbox" checked={differencesOnly} onChange={event => setDifferencesOnly(event.target.checked)}/><span>Show differences only</span><b>{differingRows.length}</b></label></div>
<div className="ac-compare-selectors"><span className="ac-compare-side-label">THE LINEUP <span aria-hidden="true">↗</span></span>{([first, second] as const).map((product, index) => <label key={index}><span>{index === 0 ? 'First option' : 'Second option'}</span><select aria-label={index === 0 ? 'First comparison option' : 'Second comparison option'} value={product.id} onChange={event => selectProduct(index as 0 | 1, event.target.value)}>{products.map(option => <option key={option.id} value={option.id} disabled={option.id === (index === 0 ? second.id : first.id)}>{option.name}</option>)}</select></label>)}</div>
<table className="ac-compare-table" role="table"><caption className="ac-compare-sr">{first.name} compared with {second.name}</caption><colgroup><col/><col/><col/></colgroup><thead><tr><th scope="col"><span className="ac-compare-sr">Feature</span><span aria-hidden="true" className="ac-compare-table-label">A LITTLE CLARITY<br/>GOES A LONG WAY.</span></th>{[first, second].map((product, index) => <th key={product.id} scope="col" data-option={index}><div className="ac-compare-product-heading"><span className="ac-compare-product-index" aria-hidden="true">0{products.indexOf(product) + 1}</span>{product.badge && <span className="ac-compare-badge">{product.badge}</span>}<h3>{product.name}</h3>{product.description && <p>{product.description}</p>}{product.price && <div className="ac-compare-price">{product.price}{product.priceNote && <small>{product.priceNote}</small>}</div>}</div></th>)}</tr></thead>{groups.map(group => <CompareGroup key={group} group={group} rows={rows.filter(row => (row.group ?? 'Details') === group)} products={[first, second]} renderValue={valueContent}/>)}{rows.length === 0 && <tbody><tr><td colSpan={3} className="ac-compare-empty">{differencesOnly ? 'These options match on every listed feature.' : 'No features have been added yet.'}</td></tr></tbody>}{[first, second].some(product => product.href) && <tfoot><tr><td/><td>{first.href && <a href={first.href}>{first.actionLabel ?? `Explore ${first.name}`}<span aria-hidden="true">↗</span></a>}</td><td>{second.href && <a href={second.href}>{second.actionLabel ?? `Explore ${second.name}`}<span aria-hidden="true">↗</span></a>}</td></tr></tfoot>}</table>
<p className="ac-compare-result" aria-live="polite" aria-atomic="true">{rows.length} of {features.length} features shown <span aria-hidden="true">/</span> Comparing {first.name} and {second.name}</p>
</section>;
}
function CompareGroup({ group, rows, products, renderValue }: { group: string; rows: FeatureComparisonRow[]; products: readonly [FeatureComparisonProduct, FeatureComparisonProduct]; renderValue: (value: string | boolean | null | undefined) => ReactNode }) {
return <tbody><tr className="ac-compare-group"><th colSpan={3} scope="rowgroup">{group}</th></tr>{rows.map(row => <tr key={row.id} className="ac-compare-feature" data-different={(row.values[products[0].id] ?? null) !== (row.values[products[1].id] ?? null)}><th scope="row"><span>{row.label}</span>{row.description && <small>{row.description}</small>}</th>{products.map((product, index) => <td key={product.id} data-option={index}>{renderValue(row.values[product.id])}</td>)}</tr>)}</tbody>;
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/feature-comparison.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { FeatureComparison } from '@/components/anticraft/FeatureComparison';
export function Example() {
return <FeatureComparison
title="Find your fit."
description="Compare the details that matter to your team."
products={[
{ id: 'solo', name: 'Solo', price: '$12', priceNote: '/ month', href: '/plans/solo' },
{ id: 'team', name: 'Team', price: '$24', priceNote: '/ month', badge: 'More room', href: '/plans/team' },
{ id: 'scale', name: 'Scale', price: '$40', priceNote: '/ month', href: '/plans/scale' },
]}
features={[
{ id: 'projects', group: 'Workspace', label: 'Active projects', values: { solo: '10', team: 'Unlimited', scale: 'Unlimited' } },
{ id: 'export', group: 'Workspace', label: 'Export your work', values: { solo: true, team: true, scale: true } },
{ id: 'reviews', group: 'Collaboration', label: 'Client reviews', values: { solo: false, team: true, scale: true } },
]}
/>;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
products | FeatureComparisonProduct[] | Required | At least two options with unique id and name. Optional description, badge, price, priceNote, href, and actionLabel appear in each header. Prices are preformatted display data; this component does not calculate charges. With fewer than two options, it renders nothing. |
features | FeatureComparisonRow[] | Required | Rows with unique id, label, optional description and group, and a values record keyed by product id. Values are strings, booleans or null. Booleans display Included / Not included, and null or a missing value displays Not specified. Keep values concise. Groups retain their first appearance order. |
defaultProductIds | readonly [string, string] | First two products | Initial first and second option IDs. Options stay distinct. If props remove a selection, it safely falls back to available products without invoking a callback. |
onSelectionChange | (selection: FeatureComparisonSelection) => void | undefined | Called only when a visitor selects another option. Receives {first,second}, the complete current product objects. Use a Client Component when passing a callback in Next.js. The initial mount, filter changes, and prop fallbacks do not invoke it. |
title / description / eyebrow | string | 'Find your fit.' / undefined / 'A CLOSER LOOK' | Heading, optional supporting introduction, and compact label. Newlines in the heading are preserved. |
products[].href / actionLabel | string | undefined / 'Explore ' + name | Optional real product, plan, or service destination. Links preserve native navigation, including modified clicks. Replace example routes with your own. Omitting href removes that option’s action. |
className | string | '' | Additional classes. Customize --ac-compare-paper, --ac-compare-ink, --ac-compare-muted, --ac-compare-line, --ac-compare-accent, and --ac-compare-focus on the root. The second option receives the accent surface. Maximum composition width is 1600px. |
Motion & accessibility
Native select controls choose two distinct options. The checkbox filters rows whose values differ (missing and null are equivalent); use consistent value types and formatting across products. All comparison information stays in a semantic table with named row and column headers; phone layouts put each feature label on its own line above two readable values. The result count is politely announced. Ordinary anchors open your supplied destinations. Reduced motion removes link transitions. The demo’s plan links lead to local explanatory sections; its example prices do not charge or collect anything.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
Dedicated comparison sections informed the useful side-by-side structure. Anticraft’s original composition adds distinct option selectors, grouped specifications, a differences filter, and compact container styling.
Visit Shadcnblocks comparisonsThe component source and demo artwork are original to Anticraft UI. The linked site is the design or content reference.