# Comparison 1

> An us-versus-alternatives table that becomes cards on mobile

A shadcn/ui block from the [@shadcnstore](https://www.shadcnstore.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnstore/comparison-1).

- Page, with a live preview: https://shadcn.exchange/r/shadcnstore/comparison-1
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnstore/comparison-1-light-44e242ad1d94effa.png
- Registry item: https://shadcnstore.com/r/comparison-1.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnstore.com/r/comparison-1.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: `lucide-react`
- Registry items: `badge`, `button`, `table`

## Files

### `storage/app/registry-sources/marketing/comparison/comparison-1/page.tsx`

```tsx
import { Comparison1 } from "@/components/blocks/marketing/comparison/comparison-1/components/comparison-1"

export default function Page() {
  return (
    <div className="flex min-h-svh w-full items-start justify-center">
      <div className="w-full">
        <Comparison1 />
      </div>
    </div>
  )
}
```

### `storage/app/registry-sources/marketing/comparison/comparison-1/comparison-1.tsx`

```tsx
import { Fragment } from 'react'
import { Check, X } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'
import {
  comparisonColumns,
  comparisonCopy,
  comparisonGroups,
  type CellValue,
} from "@/components/data/comparison-1-data"

/**
 * No hooks and no handlers, so this renders as a server component. Comparison content is
 * reference material: the mobile card view is duplicated markup rather than a JS-driven
 * transform, which keeps it readable to crawlers and assistive tech alike.
 */

function CellContent({ value }: { value: CellValue }) {
  if (value === true) {
    return (
      <>
        <Check className='text-primary mx-auto size-4' aria-hidden='true' />
        <span className='sr-only'>Included</span>
      </>
    )
  }

  if (value === false) {
    return (
      <>
        <X className='text-muted-foreground/50 mx-auto size-4' aria-hidden='true' />
        <span className='sr-only'>Not included</span>
      </>
    )
  }

  if (value === 'partial') {
    /*
      The word on its own. A `Minus` glyph in front of it read as an em dash mid-sentence
      rather than as a state marker, so the cell said "— Limited" where the two columns beside
      it said nothing of the kind. "Limited" is already a word, so it needs no icon to be
      understood, and dropping it removes the only punctuation-shaped thing in the table.
    */
    return <span className='text-muted-foreground text-sm'>Limited</span>
  }

  return <span className='text-sm'>{value}</span>
}

export function Comparison1() {
  return (
    <section aria-labelledby='comparison-heading' className='w-full py-16 sm:py-24'>
      <div className='mx-auto w-full max-w-7xl px-4 sm:px-6 lg:px-8'>
        <div className='max-w-2xl'>
          <p className='text-primary text-sm font-semibold'>{comparisonCopy.eyebrow}</p>
          <h2
            id='comparison-heading'
            className='mt-3 text-3xl font-semibold tracking-tight sm:text-4xl'
          >
            {comparisonCopy.heading}
          </h2>
          <p className='text-muted-foreground mt-4 text-lg'>{comparisonCopy.lede}</p>
        </div>

        {/*
          Table from md up. A side-scrolling matrix at 375px reliably fails, so phones get cards.

          `mt-16`, not `mt-12`: the recommended column is a tinted block that starts at the very
          top of its header cell, so at the shorter gap its edge came within a few pixels of the
          lede above and read as though the table had collided with the copy. The extra space is
          for that block, not for the table.
        */}
        <div className='mt-16 hidden md:block'>
          <Table>
            <TableCaption className='sr-only'>
              Feature comparison between ShadcnStore and two alternatives
            </TableCaption>
            <TableHeader>
              <TableRow className='hover:bg-transparent'>
                <TableHead scope='col' className='w-[34%]'>
                  <span className='sr-only'>Feature</span>
                </TableHead>
                {comparisonColumns.map(column => (
                  <TableHead
                    key={column.name}
                    scope='col'
                    className={cn(
                      // The padding is on every header, not only the tinted one. Scoped to the
                      // highlight it pushed that column's name a row lower than its neighbours,
                      // so the three headings no longer shared a baseline — the first thing the
                      // eye checks in a comparison table.
                      'pt-5 pb-4 text-center align-top',
                      // A rounded top so the tint reads as a panel behind the column rather
                      // than a slab butting into the copy above it.
                      column.highlight && 'bg-primary/5 rounded-t-lg',
                    )}
                  >
                    <span className='text-foreground block font-semibold'>{column.name}</span>
                    {column.note ? (
                      <span className='text-muted-foreground block text-xs font-normal'>
                        {column.note}
                      </span>
                    ) : null}
                    {column.highlight ? (
                      <Badge className='bg-primary/10 text-primary hover:bg-primary/10 mt-2 rounded-full'>
                        Recommended
                      </Badge>
                    ) : null}
                  </TableHead>
                ))}
              </TableRow>
            </TableHeader>

            <TableBody>
              {comparisonGroups.map((group, groupIndex) => (
                <Fragment key={group.title}>
                  {/*
                    The band carries its own cells rather than one `colSpan` heading.

                    A single spanning cell cannot be tinted per column, so the recommended
                    column's panel stopped dead at every band and restarted below it — four
                    breaks down one stripe, which read as a rendering fault rather than as
                    emphasis. The label keeps `scope='rowgroup'`; the cells beside it are
                    empty and exist only to carry the tint through.
                  */}
                  <TableRow className='bg-muted/50 hover:bg-muted/50'>
                    <th
                      scope='rowgroup'
                      className='text-muted-foreground px-4 py-2 text-left text-xs font-medium tracking-wide uppercase'
                    >
                      {group.title}
                    </th>
                    {comparisonColumns.map(column => (
                      <td
                        key={column.name}
                        aria-hidden='true'
                        className={cn(column.highlight && 'bg-primary/5')}
                      />
                    ))}
                  </TableRow>

                  {group.rows.map((row, rowIndex) => {
                    const isLastRow =
                      groupIndex === comparisonGroups.length - 1 &&
                      rowIndex === group.rows.length - 1

                    return (
                      <TableRow key={row.feature}>
                        <th scope='row' className='px-4 py-3 text-left text-sm font-normal'>
                          {row.feature}
                        </th>
                        {row.values.map((value, index) => (
                          <TableCell
                            key={comparisonColumns[index].name}
                            className={cn(
                              'text-center',
                              comparisonColumns[index].highlight && 'bg-primary/5',
                              // Matches the rounded top on the header, so the panel is closed
                              // at both ends instead of being cut off square at the bottom.
                              comparisonColumns[index].highlight && isLastRow && 'rounded-b-lg',
                            )}
                          >
                            <CellContent value={value} />
                          </TableCell>
                        ))}
                      </TableRow>
                    )
                  })}
                </Fragment>
              ))}
            </TableBody>
          </Table>

          <div className='mt-8 flex justify-center'>
            <Button size='lg' nativeButton={false} render={<a href={comparisonCopy.ctaHref} />}>
              {comparisonCopy.ctaLabel}
            </Button>
          </div>
        </div>

        {/* Phones: one card per column, highlighted column first, identical information. */}
        <div className='mt-10 flex flex-col gap-6 md:hidden'>
          {comparisonColumns
            .map((column, index) => ({ column, index }))
            .sort((a, b) => Number(Boolean(b.column.highlight)) - Number(Boolean(a.column.highlight)))
            .map(({ column, index }) => (
              <div
                key={column.name}
                className={cn('border-border rounded-xl border p-5', column.highlight && 'bg-primary/5')}
              >
                <div className='flex items-center justify-between gap-2'>
                  <div>
                    <p className='font-semibold'>{column.name}</p>
                    {column.note ? (
                      <p className='text-muted-foreground text-xs'>{column.note}</p>
                    ) : null}
                  </div>
                  {column.highlight ? (
                    <Badge className='bg-primary/10 text-primary hover:bg-primary/10 rounded-full'>
                      Recommended
                    </Badge>
                  ) : null}
                </div>

                {comparisonGroups.map(group => (
                  <div key={group.title} className='mt-5'>
                    <p className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
                      {group.title}
                    </p>
                    <dl className='divide-border mt-2 divide-y'>
                      {group.rows.map(row => (
                        <div
                          key={row.feature}
                          className='flex items-center justify-between gap-4 py-2.5'
                        >
                          <dt className='text-sm'>{row.feature}</dt>
                          <dd className='shrink-0 text-right'>
                            <CellContent value={row.values[index]} />
                          </dd>
                        </div>
                      ))}
                    </dl>
                  </div>
                ))}

                {column.highlight ? (
                  <Button className='mt-5 w-full' nativeButton={false} render={<a href={comparisonCopy.ctaHref} />}>
                    {comparisonCopy.ctaLabel}
                  </Button>
                ) : null}
              </div>
            ))}
        </div>

        <p className='text-muted-foreground mx-auto mt-8 max-w-3xl text-center text-xs'>
          {comparisonCopy.footnote}
        </p>
      </div>
    </section>
  )
}

export default Comparison1
```

### `storage/app/registry-sources/marketing/comparison/comparison-1/comparison-1-data.ts`

```ts
/**
 * `true` renders an included check, `false` a not-included mark, `'partial'` renders
 * "Limited", and any other string renders verbatim. Icons always ship with text, so no
 * cell depends on colour or shape alone.
 */
export type CellValue = boolean | 'partial' | string

export interface ComparisonColumn {
  name: string
  /** Exactly one column should set this: it is the only tinted surface in the block. */
  highlight?: boolean
  note?: string
}

export interface ComparisonGroup {
  title: string
  rows: { feature: string; values: CellValue[] }[]
}

export const comparisonCopy = {
  eyebrow: 'Compare',
  heading: 'How ShadcnStore compares',
  lede: 'The differences that actually change how a team works, rather than a feature count.',
  /** Dated claims: comparison copy goes stale fast, and saying so is part of being credible. */
  footnote:
    'Compiled from publicly documented plans in July 2026. Vendors change packaging often, so check their current pricing pages before deciding.',
  ctaLabel: 'Start free',
  ctaHref: '#start-free',
}

export const comparisonColumns: ComparisonColumn[] = [
  { name: 'ShadcnStore', highlight: true, note: 'From $0' },
  { name: 'Meridian', note: 'From $49' },
  { name: 'Halcyon', note: 'From $79' },
]

export const comparisonGroups: ComparisonGroup[] = [
  {
    title: 'Getting started',
    rows: [
      { feature: 'Free tier', values: [true, false, 'Trial only'] },
      { feature: 'Seats included', values: ['Unlimited', '5', '10'] },
      { feature: 'Setup without a sales call', values: [true, 'partial', false] },
    ],
  },
  {
    title: 'Building',
    rows: [
      { feature: 'Version-controlled environments', values: [true, true, false] },
      { feature: 'Local development runtime', values: [true, false, false] },
      { feature: 'Conditional branching', values: ['partial', true, true] },
      { feature: 'Public API coverage', values: ['Full', 'Partial', 'Partial'] },
    ],
  },
  {
    title: 'Operating',
    rows: [
      { feature: 'Run history retention', values: ['12 months', '90 days', '30 days'] },
      { feature: 'Audit log export', values: [true, true, 'partial'] },
      { feature: 'SSO and SCIM', values: [true, 'Enterprise only', 'Enterprise only'] },
    ],
  },
  {
    title: 'Support',
    rows: [
      { feature: 'Median first response', values: ['4 hours', '1 business day', '2 business days'] },
      { feature: 'Engineers on the support rota', values: [true, false, false] },
    ],
  },
]
```
