Block
Comparison table
A feature comparison table with a differences-only toggle and a sticky header.
- #arc
- #block
- #table
- #comparison
- #pricing
- #features
- #marketing
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/comparison-table.jsonFiles
components/arc/blocks/comparison-table/comparison-table-data.tscomponents/arc/blocks/comparison-table/comparison-table.module.csscomponents/arc/blocks/comparison-table/comparison-table.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdowncomparison-table-data.tscomparison-table-data.tscomparison-table-data.tsts
import type { ReactNode } from "react";
import { BRAND_NAME } from "../../lib/brand-mark";
/** 비교표에 쓰는 예시 데이터예요. 경쟁 제품 이름은 실제 제품이 아니라 일반적인 범주예요. */
/** true is included, false is not, "partial" is limited, and a string prints as text (a limit, a price). */
export type ComparisonValue = boolean | "partial" | string | { value: boolean | "partial" | string; note?: string };
export type ComparisonColumn = {
id: string;
name: string;
/** A short line under the name, such as a price. */
caption?: string;
/** Your product. It gets the tinted band and stays visible on phones. */
highlight?: boolean;
/** A mark before the name. Your own column shows the devs7udio mark by default. */
mark?: ReactNode;
};
export type ComparisonRow = {
id: string;
feature: string;
/** Secondary text under the feature name. */
hint?: string;
values: Record<string, ComparisonValue>;
};
export type ComparisonSection = { id: string; title: string; rows: ComparisonRow[] };
export const comparisonColumns: ComparisonColumn[] = [
{ id: "relay", name: BRAND_NAME, caption: "1명당 월 ₩14,000", highlight: true },
{ id: "suite", name: "기존 통합 도구", caption: "1명당 월 ₩33,000" },
{ id: "sheets", name: "스프레드시트", caption: "무료" },
{ id: "point", name: "단일 기능 도구", caption: "1명당 월 ₩22,000" },
];
export const comparisonSections: ComparisonSection[] = [
{
id: "planning", title: "계획", rows: [
{ id: "projects", feature: "프로젝트", values: { relay: "무제한", suite: "50개까지", sheets: "무제한", point: "10개까지" } },
{ id: "roadmaps", feature: "타임라인과 로드맵", values: { relay: true, suite: true, sheets: false, point: { value: "partial", note: "타임라인만" } } },
{ id: "dependencies", feature: "작업 의존 관계", hint: "막힌 작업은 선행 작업이 끝나면 함께 움직여요", values: { relay: true, suite: true, sheets: false, point: false } },
{ id: "templates", feature: "템플릿", values: { relay: true, suite: true, sheets: { value: "partial", note: "직접 복사" }, point: true } },
],
},
{
id: "collaboration", title: "협업", rows: [
{ id: "realtime", feature: "실시간 동시 편집", values: { relay: true, suite: false, sheets: true, point: true } },
{ id: "guests", feature: "게스트 무료 초대", values: { relay: true, suite: false, sheets: true, point: { value: "partial", note: "게스트 5명까지" } } },
{ id: "comments", feature: "댓글과 멘션", values: { relay: true, suite: true, sheets: true, point: true } },
{ id: "offline", feature: "오프라인 모드", hint: "다시 연결하면 수정한 내용이 동기화돼요", values: { relay: true, suite: false, sheets: { value: "partial", note: "데스크톱 앱만" }, point: false } },
],
},
{
id: "automation", title: "자동화", rows: [
{ id: "rules", feature: "규칙과 트리거", values: { relay: true, suite: true, sheets: false, point: { value: "partial", note: "유료 추가 기능" } } },
{ id: "ai", feature: "AI 요약", values: { relay: true, suite: false, sheets: false, point: false } },
{ id: "api", feature: "공개 API", values: { relay: true, suite: true, sheets: true, point: true } },
],
},
{
id: "security", title: "보안", rows: [
{ id: "sso", feature: "SAML 싱글 사인온", values: { relay: true, suite: true, sheets: { value: "partial", note: "엔터프라이즈 요금제" }, point: false } },
{ id: "audit", feature: "감사 로그", values: { relay: true, suite: true, sheets: false, point: false } },
{ id: "residency", feature: "EU 데이터 보관", values: { relay: true, suite: { value: "partial", note: "엔터프라이즈 요금제" }, sheets: true, point: false } },
],
},
];
comparison-table.module.csscomparison-table.module.csscomparison-table.module.csscss
.root { width: 100%; min-width: 0; container: compare / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.inner { display: grid; gap: var(--space-8); max-width: 1080px; margin: 0 auto; padding: var(--space-16) var(--space-6); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: var(--space-6); }
.intro { display: grid; gap: var(--space-3); max-width: 560px; }
.title { margin: 0; font-family: var(--font-display); font-size: clamp(var(--text-2xl), 1rem + 3cqi, var(--text-4xl)); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.description { margin: 0; color: var(--text-secondary); font-size: var(--text-base); line-height: var(--leading-body); text-wrap: pretty; }
.toggle { position: relative; display: inline-flex; align-items: center; gap: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); font-weight: 500; cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; }
.toggle input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.switch { position: relative; width: 38px; height: 22px; flex: none; border-radius: var(--radius-pill); background: var(--control-track); transition: background-color var(--duration-fast) var(--ease-standard); }
.thumb { position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: var(--control-thumb); box-shadow: var(--control-thumb-shadow); transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard); }
.toggle input:checked + .switch { background: var(--control-on); }
.toggle input:checked + .switch .thumb { background: var(--control-thumb-on); transform: translateX(16px); }
.toggle:has(input:checked) { color: var(--foreground); }
@media (hover: hover) and (pointer: fine) { .toggle:hover input:not(:checked) + .switch { background: var(--control-track-hover); } }
.picker { display: flex; gap: 2px; margin: 0 calc(var(--space-4) * -1); overflow-x: auto; padding: 0 var(--space-4); scrollbar-width: none; }
.picker::-webkit-scrollbar { display: none; }
.pick { position: relative; isolation: isolate; flex: none; height: 36px; border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-4); background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }
.pick[aria-pressed="true"] { color: var(--foreground); }
.pickHighlight { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: var(--surface-muted); }
.scroller { position: relative; overscroll-behavior: contain; scrollbar-width: thin; }
.table { --band: color-mix(in oklab, var(--accent) 6%, var(--surface)); --grid: minmax(0, 1.7fr) repeat(var(--columns), minmax(0, 1fr)); display: grid; }
.head { position: sticky; z-index: 3; top: var(--sticky-top, 0); background: color-mix(in oklab, var(--background) 94%, transparent); backdrop-filter: blur(14px) saturate(1.4); -webkit-backdrop-filter: blur(14px) saturate(1.4); }
.headRow, .row, .sectionRow, .footRow { display: grid; grid-template-columns: var(--grid); }
.headRow { border-bottom: 1px solid var(--border); }
.headFeature { min-height: 84px; }
.headCell { position: relative; display: grid; min-height: 84px; align-items: end; justify-items: center; overflow: hidden; padding: var(--space-4) var(--space-2) var(--space-4); text-align: center; }
.headCell[data-own] { border-radius: 20px 20px 0 0; background: var(--band); }
.headText { display: grid; gap: 2px; }
.headName { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }
.headCell[data-own] .headName { color: var(--accent-strong); }
:global([data-theme="dark"]) .headCell[data-own] .headName { color: var(--foreground); }
.headMark { flex: none; }
.headCaption { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }
.section { display: contents; }
.sectionRow { overflow: hidden; }
.sectionTitle { padding: var(--space-8) var(--space-2) var(--space-2) 0; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; }
.band[data-own] { background: var(--band); }
.row { overflow: hidden; }
.row > * { border-bottom: 1px solid var(--border-subtle); transition: background-color var(--duration-fast) var(--ease-standard); }
.feature { display: grid; align-content: center; gap: 2px; min-height: 56px; padding: var(--space-3) var(--space-4) var(--space-3) 0; font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
.hint { color: var(--text-muted); font-size: var(--text-xs); font-weight: 400; }
.cell { position: relative; display: grid; place-items: center; padding: var(--space-2); text-align: center; }
.cell[data-own] { background: var(--band); }
.cellInner { display: grid; place-items: center; }
@media (hover: hover) and (pointer: fine) {
.row:hover > * { background: color-mix(in oklab, var(--foreground) 2.5%, transparent); }
.row:hover > .cell[data-own] { background: color-mix(in oklab, var(--accent) 9%, var(--surface)); }
}
.markWrap { display: inline-grid; justify-items: center; gap: 2px; }
.check circle { fill: color-mix(in oklab, var(--foreground) 8%, transparent); }
.check path { fill: none; stroke: var(--foreground); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
.check[data-own] circle { fill: var(--control-on); }
.check[data-own] path { stroke: var(--control-glyph); }
.partial circle { fill: none; stroke: var(--text-muted); stroke-width: 1.5; }
.partial path { fill: var(--text-muted); }
.cross { color: var(--text-muted); opacity: .7; }
.note { max-width: 12ch; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.text { font-size: var(--text-sm); font-weight: 500; font-variant-numeric: tabular-nums; }
.cell:not([data-own]) .text { color: var(--text-secondary); font-weight: 400; }
.footRow > * { padding-top: var(--space-5); }
.footCell { display: grid; place-items: center; padding-bottom: var(--space-5); }
.footCell[data-own] { border-radius: 0 0 20px 20px; background: var(--band); }
.legend { display: flex; flex-wrap: wrap; align-content: start; gap: var(--space-2) var(--space-4); padding-right: var(--space-4); color: var(--text-muted); font-size: var(--text-xs); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend .check path { stroke-width: 2.2; }
.cta { display: inline-flex; height: var(--control-height-sm); max-width: calc(100% - var(--space-2)); align-items: center; justify-content: center; overflow: hidden; border: 0; border-radius: var(--radius-pill); padding: 0 var(--space-4); background: var(--accent); color: var(--accent-foreground); font: inherit; font-size: var(--text-sm); font-weight: 500; text-decoration: none; white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
a.cta { transition: transform var(--duration-instant) var(--ease-standard); }
a.cta:active { transform: scale(.97); }
.cta[data-done] { background: color-mix(in oklab, var(--success) 14%, transparent); color: var(--success); }
.ctaLabel { display: inline-flex; align-items: center; gap: 6px; }
@container compare (max-width: 820px) {
.table { --grid: minmax(0, 1.4fr) repeat(var(--columns), minmax(0, 1fr)); }
}
@container compare (max-width: 640px) {
.inner { gap: var(--space-6); padding: var(--space-12) var(--space-4); }
.table { --grid: minmax(0, 1.3fr) repeat(var(--columns), minmax(0, .9fr)); }
.headFeature, .headCell { min-height: 72px; }
.legend { flex-direction: column; }
.feature { padding-right: var(--space-3); }
}
@media (prefers-reduced-motion: reduce) {
.thumb { transition: none; }
}
comparison-table.tsxcomparison-table.tsxcomparison-table.tsxtsx
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { AnimatePresence, LayoutGroup, motion } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { motionTokens } from "../../lib/motion-tokens";
import { comparisonColumns, comparisonSections } from "./comparison-table-data";
import type { ComparisonColumn, ComparisonSection, ComparisonValue } from "./comparison-table-data";
import styles from "./comparison-table.module.css";
import { BRAND_NAME, Devs7udioMark } from "../../lib/brand-mark";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";
export type { ComparisonColumn, ComparisonRow, ComparisonSection, ComparisonValue } from "./comparison-table-data";
export interface ComparisonTableProps {
title?: string;
description?: string;
columns?: ComparisonColumn[];
sections?: ComparisonSection[];
/** Hide rows where every visible column has the same value (controlled). */
differencesOnly?: boolean;
defaultDifferencesOnly?: boolean;
onDifferencesOnlyChange?: (value: boolean) => void;
/** Competitor shown beside yours in the stacked phone layout (controlled). */
compareWith?: string;
onCompareWithChange?: (id: string) => void;
/** Call to action in your column. */
cta?: { label: string; href?: string; onClick?: () => void; doneLabel?: string };
/** Offset for the sticky header, such as the height of a fixed site header. 기본은 0이에요. */
stickyTop?: number;
/** Caps the table height and scrolls it inside the block, with the header sticking to its top. */
maxHeight?: number | string;
/** Width below which the table stacks into a two column comparison. 기본은 640이에요. */
stackBelow?: number;
className?: string;
}
const snappy = motionTokens.spring.snappy;
const smooth = motionTokens.spring.smooth;
const morph = motionTokens.spring.morph;
function useControllable<T>(value: T | undefined, initial: T, onChange?: (next: T) => void) {
const [inner, setInner] = useState(initial);
const current = value !== undefined ? value : inner;
const set = (next: T) => { if (value === undefined) setInner(next); onChange?.(next); };
return [current, set] as const;
}
function normalize(value: ComparisonValue | undefined) {
if (value === undefined) return { value: false as const, note: undefined };
if (typeof value === "object") return value;
return { value, note: undefined };
}
const keyOf = (value: ComparisonValue | undefined) => { const item = normalize(value); return `${item.value}`; };
function Mark({ value, own, index, reduced }: { value: ComparisonValue | undefined; own: boolean; index: number; reduced: boolean }) {
const { value: v, note } = normalize(value);
if (typeof v === "string" && v !== "partial") return <span className={styles.text}>{v}</span>;
if (v === true) return <span className={styles.markWrap}>
<svg className={styles.check} data-own={own || undefined} width="22" height="22" viewBox="0 0 22 22" aria-hidden="true">
<circle cx="11" cy="11" r="10" />
<motion.path d="M6.6 11.3l3 3 5.9-6.4" initial={reduced ? false : { pathLength: 0 }} whileInView={{ pathLength: 1 }} viewport={{ once: true, amount: 1 }} transition={{ duration: motionTokens.duration.considered, ease: [...motionTokens.ease.enter], delay: own ? .06 + Math.min(index, 12) * .035 : 0 }} />
</svg>
<span className={styles.srOnly}>포함돼요</span>
</span>;
if (v === "partial") return <span className={styles.markWrap}>
<svg className={styles.partial} width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="9.25" /><path d="M11 1.75a9.25 9.25 0 0 1 0 18.5z" /></svg>
<span className={styles.srOnly}>일부 포함돼요</span>
{note && <span className={styles.note}>{note}</span>}
</span>;
return <span className={styles.markWrap}><UiIcon iconRole="minus" className={styles.cross} size={18} strokeWidth={1.75} aria-hidden="true" /><span className={styles.srOnly}>포함되지 않아요</span></span>;
}
export function ComparisonTable({
title = `${BRAND_NAME}는 어떻게 다를까요?`,
description = "성장하는 팀에 필요한 기능만 담았어요. 엔터프라이즈 가격도, 흩어진 스프레드시트도 필요 없어요.",
columns = comparisonColumns,
sections = comparisonSections,
differencesOnly: differencesProp,
defaultDifferencesOnly = false,
onDifferencesOnlyChange,
compareWith: compareProp,
onCompareWithChange,
cta,
stickyTop = 0,
maxHeight,
stackBelow = 640,
className,
}: ComparisonTableProps) {
const reduced = Boolean(useReducedMotion());
const uid = useId();
const rootRef = useRef<HTMLElement>(null);
const [narrow, setNarrow] = useState(false);
const [differencesOnly, setDifferencesOnly] = useControllable(differencesProp, defaultDifferencesOnly, onDifferencesOnlyChange);
const own = columns.find(column => column.highlight) ?? columns[0]!;
const others = columns.filter(column => column !== own);
const [compareWith, setCompareWith] = useControllable(compareProp, others[0]?.id ?? own.id, onCompareWithChange);
const [ctaDone, setCtaDone] = useState(false);
useEffect(() => {
const node = rootRef.current;
if (!node) return;
const observer = new ResizeObserver(([entry]) => { if (entry) setNarrow(entry.contentRect.width < stackBelow); });
observer.observe(node);
return () => observer.disconnect();
}, [stackBelow]);
const visible = narrow ? columns.filter(column => column === own || column.id === compareWith) : columns;
const differs = (values: Record<string, ComparisonValue>) => new Set(visible.map(column => keyOf(values[column.id]))).size > 1;
const gridStyle = { "--columns": visible.length, "--sticky-top": `${stickyTop}px` } as CSSProperties;
let rowIndex = 0;
const header = <div role="row" className={styles.headRow}>
<div role="columnheader" className={styles.headFeature}><span className={styles.srOnly}>기능</span></div>
{visible.map(column => <div key={column === own ? "own" : narrow ? "other" : column.id} role="columnheader" className={styles.headCell} data-own={column === own || undefined}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={column.id} className={styles.headText} initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8 }} transition={smooth}>
<span className={styles.headName}>{column.mark ?? (column === own ? <Devs7udioMark size={18} className={styles.headMark} /> : null)}{column.name}</span>
{column.caption && <span className={styles.headCaption}>{column.caption}</span>}
</motion.span>
</AnimatePresence>
</div>)}
</div>;
return <section ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} aria-labelledby={`${uid}-title`} data-narrow={narrow || undefined}>
<div className={styles.inner}>
<header className={styles.header}>
<div className={styles.intro}>
<h2 id={`${uid}-title`} className={styles.title}>{title}</h2>
{description && <p className={styles.description}>{description}</p>}
</div>
<label className={styles.toggle}>
<span>차이점만 보기</span>
<input type="checkbox" role="switch" checked={differencesOnly} onChange={event => { playUiSound("tick"); setDifferencesOnly(event.target.checked); }} />
<span className={styles.switch} aria-hidden="true"><span className={styles.thumb} /></span>
</label>
</header>
{narrow && others.length > 1 && <div className={styles.picker} role="group" aria-label={`${own.name} 비교 대상`}>
<LayoutGroup id={`${uid}-picker`}>
{others.map(column => <button key={column.id} type="button" className={styles.pick} aria-pressed={column.id === compareWith} onClick={() => { playUiSound("tick"); setCompareWith(column.id); }}>
{column.id === compareWith && <motion.span layoutId="pick" className={styles.pickHighlight} transition={reduced ? { duration: 0 } : morph} />}
<span>{column.name}</span>
</button>)}
</LayoutGroup>
</div>}
<div className={styles.scroller} style={maxHeight !== undefined ? { maxHeight, overflowY: "auto" } : undefined} tabIndex={maxHeight !== undefined ? 0 : undefined} aria-label={maxHeight !== undefined ? title : undefined} role={maxHeight !== undefined ? "region" : undefined}>
<div role="table" aria-labelledby={`${uid}-title`} className={styles.table} style={gridStyle}>
<div role="rowgroup" className={styles.head}>{header}</div>
{sections.map(section => {
const rows = differencesOnly ? section.rows.filter(row => differs(row.values)) : section.rows;
return <div role="rowgroup" key={section.id} className={styles.section}>
<AnimatePresence initial={false}>
{rows.length > 0 && <motion.div key="title" role="row" className={styles.sectionRow} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : smooth}>
<div role="rowheader" className={styles.sectionTitle}>{section.title}</div>
{visible.map(column => <div key={column.id} role="cell" className={styles.band} data-own={column === own || undefined} />)}
</motion.div>}
{rows.map(row => {
const index = rowIndex++;
return <motion.div key={row.id} role="row" className={styles.row} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={reduced ? { duration: 0 } : smooth}>
<div role="rowheader" className={styles.feature}>
<span>{row.feature}</span>
{row.hint && <span className={styles.hint}>{row.hint}</span>}
</div>
{visible.map(column => <div key={column === own ? "own" : narrow ? "other" : column.id} role="cell" className={styles.cell} data-own={column === own || undefined}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={`${column.id}`} className={styles.cellInner} initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .8 }} animate={{ opacity: 1, scale: 1 }} exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .8 }} transition={snappy}>
<Mark value={row.values[column.id]} own={column === own} index={index} reduced={reduced} />
</motion.span>
</AnimatePresence>
</div>)}
</motion.div>;
})}
</AnimatePresence>
</div>;
})}
<div role="rowgroup">
<div role="row" className={styles.footRow}>
<div role="cell" className={styles.legend}>
<span><svg className={styles.check} width="16" height="16" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="10" /><path d="M6.6 11.3l3 3 5.9-6.4" /></svg>포함돼요</span>
<span><svg className={styles.partial} width="16" height="16" viewBox="0 0 22 22" aria-hidden="true"><circle cx="11" cy="11" r="9.25" /><path d="M11 1.75a9.25 9.25 0 0 1 0 18.5z" /></svg>일부 포함돼요</span>
<span><UiIcon iconRole="minus" className={styles.cross} size={14} strokeWidth={1.75} aria-hidden="true" />포함되지 않아요</span>
</div>
{visible.map(column => <div key={column.id} role="cell" className={styles.footCell} data-own={column === own || undefined}>
{column === own && cta && (cta.href && !cta.onClick
? <a className={styles.cta} href={cta.href}>{cta.label}</a>
: <motion.button type="button" layout={!reduced} className={styles.cta} style={{ borderRadius: 9999 }} data-done={ctaDone || undefined} whileTap={reduced ? undefined : { scale: .97 }} transition={morph} onClick={() => { cta.onClick?.(); if (cta.doneLabel) setCtaDone(true); }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={ctaDone ? "done" : "idle"} layout={reduced ? false : "position"} className={styles.ctaLabel} initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduced ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(2px)" }} transition={{ duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] }}>
{ctaDone ? <><UiIcon iconRole="check" size={14} strokeWidth={2} aria-hidden="true" />{cta.doneLabel}</> : cta.label}
</motion.span>
</AnimatePresence>
</motion.button>)}
</div>)}
</div>
</div>
</div>
</div>
<p className={styles.srOnly} aria-live="polite">{differencesOnly ? "차이가 있는 항목만 보여 주고 있어요" : "모든 항목을 보여 주고 있어요"}</p>
</div>
</section>;
}
/** Preview: the comparison inside a capped height so the sticky header shows. */
export function ComparisonTableBlock() {
return <ComparisonTable maxHeight="min(760px, 82vh)" cta={{ label: "무료로 시작하기", doneLabel: "체험을 시작했어요" }} />;
}
export default ComparisonTableBlock;