Skip to content
Commerce block

Product Comparison Table

Three products side by side with the row labels pinned through a horizontal scroll, an "only show differences" toggle that says how many rows it hid, and "best" marked only where better means something.

341 lineslucide-reactAdded 27 Aug 2026Updated 13 Sept 2026
  • compare
  • table
  • specs
  • product
  • sticky

What's included

  • components/product-compare-table.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add product-compare-table

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Comparing 3 of 3

2 rows are identical across all 3 and hidden.

Specifications compared across 3 products. 2 identical rows are hidden.
SpecificationField Over-Ear£249Field Over-Ear Lite£179Studio Reference£329
Price£249£179best£329
Battery life38 hours44 hoursbest30 hours
Weight286 g241 gbest324 g
Noise cancellingAdaptive, 3 levelsPassive onlyAdaptive, 5 levels
Driver40 mm dynamic40 mm dynamic50 mm planar
Water resistanceIPX4IPX4None
Warranty2 years2 years5 yearsbest

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/product-compare-table.tsx
'use client'

/**
 * <ProductCompareTable> — four products, side by side, differences first.
 *
 * Product Listings had the grid, the rail, the filter sidebar and the
 * collection toolbar: four ways of narrowing a catalogue down. Comparison
 * is what happens after the narrowing works and somebody is left holding
 * three plausible options, and it is the step where most storefronts hand
 * the shopper a spreadsheet or nothing at all.
 *
 * THE ONE THING THIS GETS RIGHT THAT MOST DO NOT
 *
 * "Only show differences" is the whole feature. Twenty-two rows of specs,
 * fourteen of them identical across every column, is not a comparison — it
 * is a data dump that hides the four rows the decision turns on. One
 * toggle collapses the matching rows and says how many it hid, so nothing
 * is silently gone.
 *
 * THE ATTRIBUTE COLUMN IS PINNED
 *
 * Scroll a comparison sideways on a phone and the row labels leave the
 * screen; every number after that is unattributed. `position: sticky` on
 * the row headers, with an opaque background — sticky over a transparent
 * cell is the bug where text from the scrolling column shows through the
 * one that is meant to be fixed.
 *
 * REMOVING A PRODUCT IS UNDOABLE
 *
 * Comparisons are assembled by hand, item by item, and a column dropped by
 * mistake means rebuilding the set. The remove control puts it back.
 *
 * BETTER IS MARKED, BUT ONLY WHERE BETTER MEANS SOMETHING
 *
 * A tick on the highest number is nonsense for weight and useful for
 * battery life, so the direction lives on the row rather than being
 * assumed. Rows where "best" is a matter of taste carry no mark at all —
 * quietly declaring a winner on a subjective row is how a comparison table
 * stops being trusted.
 *
 * ACCESSIBILITY: `<th scope="col">` per product and `scope="row"` per
 * attribute, so every cell is announced with both of its coordinates.
 * The differences toggle is a labelled checkbox with a live region
 * reporting what changed, and "best" is a word in the cell, not a colour.
 */

import * as React from 'react'
import { Check, Plus, X } from 'lucide-react'

export interface CompareProduct {
  id: string
  name: string
  price: string
  values: Record<string, string>
}

export interface CompareRow {
  label: string
  /** How to read the row, or 'none' where "best" is a matter of taste. */
  better: 'higher' | 'lower' | 'none'
  /**
   * Extracts the comparable number from a display value.
   *
   * Optional, and in practice you should leave it out. This block is
   * `'use client'`, so a function here cannot reach it from a server
   * component — React refuses with "Functions cannot be passed directly to
   * Client Components", at build time, during prerender. Every page in the
   * catalog is a server component, so that is nearly every caller.
   *
   * `DEFAULT_ROWS` below still uses functions because module-level defaults
   * never cross a boundary. Anyone passing `rows` gets `defaultNumeric`,
   * which handles the shapes these tables actually hold.
   */
  numeric?: (value: string) => number
}

/**
 * Pull the comparable number out of a display value.
 *
 * The fallback for every row that does not carry its own `numeric` — which
 * is every row a server component can pass, since a function cannot cross
 * that boundary into a client block. Before this existed, `better: 'higher'`
 * from a server page silently highlighted nothing, and supplying the
 * obvious fix (a `numeric` function) failed the production build rather
 * than the dev render.
 *
 * Strips everything but digits and a decimal point, so "€490/mo" reads 490,
 * "94%" reads 94 and "6 weeks" reads 6. A value with no digits yields NaN,
 * and `bestIds` already refuses to mark a winner on any row where a cell
 * does not parse — so guessing here cannot produce a wrong highlight, only
 * no highlight.
 */
function defaultNumeric(value: string): number {
  return parseFloat(value.replace(/[^\d.]/g, ''))
}

export interface ProductCompareTableProps {
  products?: CompareProduct[]
  rows?: CompareRow[]
  className?: string
}

const DEFAULT_ROWS: CompareRow[] = [
  { label: 'Price', better: 'lower', numeric: (v) => Number(v.replace(/[^\d.]/g, '')) },
  { label: 'Battery life', better: 'higher', numeric: (v) => parseFloat(v) },
  { label: 'Weight', better: 'lower', numeric: (v) => parseFloat(v) },
  { label: 'Noise cancelling', better: 'none' },
  { label: 'Driver', better: 'none' },
  { label: 'Charging', better: 'none' },
  { label: 'Water resistance', better: 'none' },
  { label: 'Warranty', better: 'higher', numeric: (v) => parseFloat(v) },
  { label: 'Replaceable battery', better: 'none' },
]

const DEFAULT_PRODUCTS: CompareProduct[] = [
  {
    id: 'a',
    name: 'Field Over-Ear',
    price: '£249',
    values: {
      Price: '£249',
      'Battery life': '38 hours',
      Weight: '286 g',
      'Noise cancelling': 'Adaptive, 3 levels',
      Driver: '40 mm dynamic',
      Charging: 'USB-C, 3.5 mm passive',
      'Water resistance': 'IPX4',
      Warranty: '2 years',
      'Replaceable battery': 'Yes, by post',
    },
  },
  {
    id: 'b',
    name: 'Field Over-Ear Lite',
    price: '£179',
    values: {
      Price: '£179',
      'Battery life': '44 hours',
      Weight: '241 g',
      'Noise cancelling': 'Passive only',
      Driver: '40 mm dynamic',
      Charging: 'USB-C, 3.5 mm passive',
      'Water resistance': 'IPX4',
      Warranty: '2 years',
      'Replaceable battery': 'Yes, by post',
    },
  },
  {
    id: 'c',
    name: 'Studio Reference',
    price: '£329',
    values: {
      Price: '£329',
      'Battery life': '30 hours',
      Weight: '324 g',
      'Noise cancelling': 'Adaptive, 5 levels',
      Driver: '50 mm planar',
      Charging: 'USB-C, 3.5 mm passive',
      'Water resistance': 'None',
      Warranty: '5 years',
      'Replaceable battery': 'Yes, by post',
    },
  },
]

export function ProductCompareTable({
  products = DEFAULT_PRODUCTS,
  rows = DEFAULT_ROWS,
  className = '',
}: ProductCompareTableProps) {
  const [removed, setRemoved] = React.useState<string[]>([])
  const [differencesOnly, setDifferencesOnly] = React.useState(true)

  const shown = products.filter((p) => !removed.includes(p.id))

  const isSame = (row: CompareRow) =>
    shown.length > 1 && new Set(shown.map((p) => p.values[row.label])).size === 1

  const visibleRows = differencesOnly ? rows.filter((r) => !isSame(r)) : rows
  const hidden = rows.length - visibleRows.length

  /* Direction comes from the row, so nothing declares a winner by guess. */
  const bestIds = (row: CompareRow) => {
    const read = row.numeric ?? defaultNumeric
    if (row.better === 'none' || shown.length < 2) return []
    const scored = shown.map((p) => ({ id: p.id, n: read(p.values[row.label] ?? '') }))
    if (scored.some((s) => Number.isNaN(s.n))) return []
    const target =
      row.better === 'higher'
        ? Math.max(...scored.map((s) => s.n))
        : Math.min(...scored.map((s) => s.n))
    return scored.filter((s) => s.n === target).map((s) => s.id)
  }

  return (
    <section className={`mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 ${className}`}>
      <div className="rounded-2xl border border-border bg-card p-5">
        <div className="flex flex-wrap items-center justify-between gap-3">
          <h2 className="text-base font-semibold text-foreground">
            Comparing {shown.length} of {products.length}
          </h2>

          <label className="flex cursor-pointer items-center gap-2 text-sm text-foreground">
            <input
              type="checkbox"
              checked={differencesOnly}
              onChange={(event) => setDifferencesOnly(event.target.checked)}
              /*
                accent-primary, not a hand-rolled box: these tokens are
                oklch(), so hsl(var(--primary)) is not a colour and the
                check renders browser-blue.
              */
              className="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            />
            Only show differences
          </label>
        </div>

        {/* Nothing is silently gone. */}
        <p aria-live="polite" className="mt-1 min-h-5 text-xs text-muted-foreground">
          {differencesOnly && hidden > 0
            ? `${hidden} ${hidden === 1 ? 'row is' : 'rows are'} identical across all ${shown.length} and hidden.`
            : 'Showing every row, identical or not.'}
        </p>

        {removed.length > 0 ? (
          <div className="mt-3 flex flex-wrap gap-2">
            {removed.map((id) => {
              const product = products.find((p) => p.id === id)
              if (!product) return null
              return (
                <button
                  key={id}
                  type="button"
                  onClick={() => setRemoved((r) => r.filter((x) => x !== id))}
                  className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-dashed border-border px-2.5 text-xs font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  <Plus aria-hidden className="h-3.5 w-3.5" />
                  Put {product.name} back
                </button>
              )
            })}
          </div>
        ) : null}

        {/*
          `relative` is load-bearing. Tailwind's `sr-only` is `position:
          absolute`, and an overflow box only clips an absolutely positioned
          descendant when it is also that descendant's containing block.
          Without it the per-column "Remove <product>" labels laid out at their static position,
          escaped this scroller and dragged the page sideways on a phone,
          pointing at nothing anyone could see. Same fix as /compare.
        */}
        <div className="relative mt-4 -mx-1 overflow-x-auto px-1">
          <table className="w-full min-w-[36rem] border-collapse text-start text-sm">
            <caption className="sr-only">
              Specifications compared across {shown.length} products.
              {differencesOnly && hidden > 0
                ? ` ${hidden} identical rows are hidden.`
                : ''}
            </caption>
            <thead>
              <tr className="border-b border-border align-bottom">
                {/* Opaque, or the scrolling column shows through it. */}
                <th
                  scope="col"
                  className="sticky start-0 z-10 w-40 bg-card px-3 py-3 text-xs font-medium text-muted-foreground"
                >
                  Specification
                </th>
                {shown.map((product) => (
                  <th key={product.id} scope="col" className="px-3 py-3">
                    <span className="flex items-start justify-between gap-2">
                      <span className="min-w-0">
                        <span className="block text-sm font-semibold text-foreground">
                          {product.name}
                        </span>
                        <span className="block text-xs font-normal text-muted-foreground">
                          {product.price}
                        </span>
                      </span>
                      <button
                        type="button"
                        onClick={() => setRemoved((r) => [...r, product.id])}
                        className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                      >
                        <X aria-hidden className="h-3.5 w-3.5" />
                        <span className="sr-only">
                          Remove {product.name} from the comparison
                        </span>
                      </button>
                    </span>
                  </th>
                ))}
              </tr>
            </thead>
            <tbody className="divide-y divide-border">
              {visibleRows.map((row) => {
                const best = bestIds(row)
                return (
                  <tr key={row.label}>
                    <th
                      scope="row"
                      className="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground"
                    >
                      {row.label}
                    </th>
                    {shown.map((product) => {
                      const isBest = best.includes(product.id)
                      return (
                        <td key={product.id} className="px-3 py-2.5 text-foreground">
                          <span className="flex items-center gap-1.5">
                            {product.values[row.label] ?? '—'}
                            {isBest ? (
                              /* A word, not only a tick and a colour. */
                              <span className="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400">
                                <Check aria-hidden className="h-3 w-3" />
                                best
                              </span>
                            ) : null}
                          </span>
                        </td>
                      )
                    })}
                  </tr>
                )
              })}
            </tbody>
          </table>
        </div>

        {shown.length < 2 ? (
          <p className="mt-4 rounded-xl border border-border bg-muted/50 px-3 py-2 text-sm text-muted-foreground">
            One product left. Put another back to compare against it.
          </p>
        ) : null}
      </div>
    </section>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/product-compare-table.tsx and import it where you need the section:

import { ProductCompareTable } from '@/components/product-compare-table'

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
productsCompareProduct[]DEFAULT_PRODUCTS
rowsCompareRow[]DEFAULT_ROWS
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

product-compare-table.html
<!--
  Product Comparison Table — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<section class="mx-auto w-full max-w-4xl px-4 py-16 sm:px-6 ">
  <div class="rounded-2xl border border-border bg-card p-5">
    <div class="flex flex-wrap items-center justify-between gap-3">
      <h2 class="text-base font-semibold text-foreground">Comparing 3 of 3</h2>
      <label class="flex cursor-pointer items-center gap-2 text-sm text-foreground"><input type="checkbox" class="h-4 w-4 rounded border-field accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" checked="" />Only show differences</label>
    </div>
    <p aria-live="polite" class="mt-1 min-h-5 text-xs text-muted-foreground">2 rows are identical across all 3 and hidden.</p>
    <div class="relative mt-4 -mx-1 overflow-x-auto px-1">
      <table class="w-full min-w-[36rem] border-collapse text-start text-sm">
        <caption class="sr-only">Specifications compared across 3 products. 2 identical rows are hidden.</caption>
        <thead>
          <tr class="border-b border-border align-bottom">
            <th scope="col" class="sticky start-0 z-10 w-40 bg-card px-3 py-3 text-xs font-medium text-muted-foreground">Specification</th>
            <th scope="col" class="px-3 py-3">
              <span class="flex items-start justify-between gap-2">
                <span class="min-w-0">
                  <span class="block text-sm font-semibold text-foreground">Field Over-Ear</span>
                  <span class="block text-xs font-normal text-muted-foreground">£249</span>
                </span>
                <button type="button" class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true">
                    <path d="M18 6 6 18"></path>
                    <path d="m6 6 12 12"></path>
                  </svg>
                  <span class="sr-only">Remove Field Over-Ear from the comparison</span>
                </button>
              </span>
            </th>
            <th scope="col" class="px-3 py-3">
              <span class="flex items-start justify-between gap-2">
                <span class="min-w-0">
                  <span class="block text-sm font-semibold text-foreground">Field Over-Ear Lite</span>
                  <span class="block text-xs font-normal text-muted-foreground">£179</span>
                </span>
                <button type="button" class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true">
                    <path d="M18 6 6 18"></path>
                    <path d="m6 6 12 12"></path>
                  </svg>
                  <span class="sr-only">Remove Field Over-Ear Lite from the comparison</span>
                </button>
              </span>
            </th>
            <th scope="col" class="px-3 py-3">
              <span class="flex items-start justify-between gap-2">
                <span class="min-w-0">
                  <span class="block text-sm font-semibold text-foreground">Studio Reference</span>
                  <span class="block text-xs font-normal text-muted-foreground">£329</span>
                </span>
                <button type="button" class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true">
                    <path d="M18 6 6 18"></path>
                    <path d="m6 6 12 12"></path>
                  </svg>
                  <span class="sr-only">Remove Studio Reference from the comparison</span>
                </button>
              </span>
            </th>
          </tr>
        </thead>
        <tbody class="divide-y divide-border">
          <tr>
            <th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Price</th>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">£249</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">£179<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">£329</span>
            </td>
          </tr>
          <tr>
            <th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Battery life</th>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">38 hours</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">44 hours<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">30 hours</span>
            </td>
          </tr>
          <tr>
            <th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Weight</th>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">286 g</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">241 g<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">324 g</span>
            </td>
          </tr>
          <tr>
            <th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Noise cancelling</th>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">Adaptive, 3 levels</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">Passive only</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">Adaptive, 5 levels</span>
            </td>
          </tr>
          <tr>
            <th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Driver</th>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">40 mm dynamic</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">40 mm dynamic</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">50 mm planar</span>
            </td>
          </tr>
          <tr>
            <th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Water resistance</th>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">IPX4</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">IPX4</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">None</span>
            </td>
          </tr>
          <tr>
            <th scope="row" class="sticky start-0 z-10 bg-card px-3 py-2.5 text-xs font-medium text-muted-foreground">Warranty</th>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">2 years</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">2 years</span>
            </td>
            <td class="px-3 py-2.5 text-foreground">
              <span class="flex items-center gap-1.5">5 years<span class="inline-flex items-center gap-0.5 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-700 dark:text-emerald-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check h-3 w-3" aria-hidden="true"><path d="M20 6 9 17l-5-5"></path></svg>best</span></span>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</section>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

8 more blocks in Product Listings

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Product Listings

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Product Listings blocks

View category
Open the full page for this block

Product Card Grid

Responsive product cards with sale badges, ratings and a wishlist button layered above the card link so it stays clickable.

Product Listings142 lines1 dep
Open the full page for this block

Faceted Filter Sidebar

Category, size, colour and price facets as proper fieldsets, with colour swatches that carry their name as well as their hue.

Product Listings220 lines1 dep
Open the full page for this block

Collection Sort Toolbar

Result count, sort and density switch — with the count in an aria-live region, since it is the only feedback a filter change gives some users.

Product Listings131 lines1 dep
Open the full page for this block

Stock Availability List

Availability per variant stated before the cart, because discovering it at checkout is where the basket gets abandoned.

Product Listings149 linesNo deps
Open the full page for this block

Collection Story Split

The editorial header above a collection grid, carrying the four facts that decide a considered purchase.

Product Listings157 linesNo deps

Map and Results Split

Results on one side, a drawn map on the other, scrolling independently. The map is explicitly decorative and out of the tab order — the list is the content, not its companion.

Product Listings329 lines1 dep
Open the full page for this block

Menu Course List

A restaurant menu typeset as a bill of fare rather than a product grid — dotted leaders to the price, dietary marks as abbreviations with a key, and prices as strings so “market price” still fits.

Product Listings282 linesNo deps