Block
Plan comparison
A two-plan comparison with a monthly and yearly billing switch.
- #arc
- #block
- #pricing
- #plans
- #comparison
- #billing
- #marketing
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/plan-comparison.jsonFiles
components/arc/blocks/plan-comparison/plan-comparison.module.csscomponents/arc/blocks/plan-comparison/plan-comparison.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownplan-comparison.module.cssplan-comparison.module.cssplan-comparison.module.csscss
.comparison { box-sizing: border-box; width: min(100%, 1060px); min-width: 0; margin-inline: auto; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); color: var(--foreground); font-family: var(--font-body); font-size: var(--text-sm); letter-spacing: var(--tracking-body); line-height: var(--leading-body); }
.comparison *, .comparison *::before, .comparison *::after { box-sizing: border-box; }
/* Intro */
.intro { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px 32px; padding: 32px 32px 24px; }
.intro > div:first-child { min-width: 0; max-width: 560px; }
.intro h2 { margin: 0; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 400; letter-spacing: var(--tracking-display); line-height: var(--leading-display); text-wrap: balance; }
.intro p { max-width: 46ch; margin: 8px 0 0; color: var(--text-secondary); text-wrap: pretty; }
.billing { display: grid; flex: none; justify-items: end; gap: 6px; }
.billing small { min-height: 16px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
/* Filter bar */
.filter { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 8px 16px; border-top: 1px solid var(--border); padding: 8px 32px; }
.filter > div:first-child { display: flex; align-items: baseline; gap: 10px; }
.filter h3 { margin: 0; font-size: var(--text-sm); font-weight: 500; }
.featureCount { min-width: 11ch; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
.filter > button { flex: none; }
/* Matrix */
.matrix { border-top: 1px solid var(--border); }
.planHeader, .featureRow { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr) minmax(0, 1fr); }
/* Plan cells share three row tracks (name, price, button), so a description that wraps in one column keeps prices and buttons level. */
.planHeader { grid-template-rows: auto auto auto; }
.featureHeader { grid-row: 1 / span 3; display: flex; align-items: end; padding: 0 16px 20px 32px; color: var(--text-muted); font-size: var(--text-xs); }
.planCell { position: relative; display: grid; grid-row: span 3; grid-template-rows: subgrid; min-width: 0; align-content: start; border-left: 1px solid var(--border); padding: 20px; transition: background-color var(--duration-standard) var(--ease-standard); }
.planCell.planSelected, .featureValue.valueSelected { background: var(--surface-muted); }
.planTop h3 { margin: 0; font-size: var(--text-base); font-weight: 500; letter-spacing: -.01em; }
.planTop > span { display: block; margin-top: 2px; color: var(--text-muted); font-size: var(--text-xs); }
.price { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 6px; margin: 16px 0 14px; }
.priceValue { display: inline-flex; min-width: 3ch; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 400; letter-spacing: var(--tracking-display); line-height: 1; font-variant-numeric: tabular-nums; }
.pricePeriod { color: var(--text-muted); font-size: var(--text-xs); white-space: nowrap; }
.selectButton { width: 100%; }
.selectedRail { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--accent); }
.rows { border-top: 1px solid var(--border); }
.featureRow { overflow: hidden; border-bottom: 1px solid var(--border-subtle); }
.featureRow:last-child { border-bottom: 0; }
.featureName, .featureValue { display: flex; min-width: 0; min-height: 44px; align-items: center; padding: 10px 20px; }
.featureName { padding-left: 32px; color: var(--foreground); }
.featureValue { border-left: 1px solid var(--border); color: var(--text-secondary); transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
.featureValue.valueSelected { color: var(--foreground); }
.included { display: inline-flex; align-items: center; gap: 6px; color: var(--foreground); }
.included svg { color: var(--success); }
.unavailable { color: var(--text-muted); }
.mobilePlan { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.footer { display: flex; min-height: 48px; align-items: center; justify-content: space-between; gap: 8px 24px; border-top: 1px solid var(--border); padding: 12px 32px; color: var(--text-muted); font-size: var(--text-xs); }
.footer p { min-height: 1.4em; margin: 0; color: var(--text-secondary); text-align: right; }
@media (hover: hover) and (pointer: fine) {
.featureRow:hover .featureName { color: var(--foreground); }
}
/* Layout follows the block width (data-size, measured in the component), not the viewport. */
/* Medium: keep three columns, tighten the gutters. */
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .intro { flex-direction: column; align-items: flex-start; padding: 24px 20px 20px; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .billing { justify-items: start; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .filter { padding-inline: 20px; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .featureHeader, .comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .featureName { padding-left: 20px; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .planCell { padding: 16px 14px; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .featureValue { padding-inline: 14px; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .footer { flex-direction: column; align-items: flex-start; padding-inline: 20px; }
.comparison:is([data-size="md"], [data-size="sm"], [data-size="xs"]) .footer p { text-align: left; }
/* Narrow: the feature name sits above its two values, which stay aligned under the plan columns. */
.comparison:is([data-size="sm"], [data-size="xs"]) .intro h2 { font-size: var(--text-2xl); }
.comparison:is([data-size="sm"], [data-size="xs"]) .filter { flex-wrap: wrap; }
.comparison:is([data-size="sm"], [data-size="xs"]) .planHeader, .comparison:is([data-size="sm"], [data-size="xs"]) .featureRow { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureHeader { display: none; }
.comparison:is([data-size="sm"], [data-size="xs"]) .planCell { border-left: 0; }
.comparison:is([data-size="sm"], [data-size="xs"]) .planCell + .planCell { border-left: 1px solid var(--border); }
.comparison:is([data-size="sm"], [data-size="xs"]) .priceValue { font-size: var(--text-2xl); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureName { grid-column: 1 / -1; min-height: 0; padding: 10px 20px 0; color: var(--text-secondary); font-size: var(--text-xs); }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureValue { min-height: 0; border-left: 0; padding: 4px 14px 10px; }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureValue:nth-child(2) { padding-left: 20px; }
.comparison:is([data-size="sm"], [data-size="xs"]) .featureValue + .featureValue { border-left: 1px solid var(--border-subtle); }
.comparison:is([data-size="sm"], [data-size="xs"]) .planCell:nth-child(2) { padding-left: 20px; }
.comparison[data-size="xs"] .planCell { padding-inline: 12px; }
.comparison[data-size="xs"] .planCell:nth-child(2) { padding-left: 16px; }
.comparison[data-size="xs"] .featureValue { padding-inline: 12px; }
.comparison[data-size="xs"] .featureValue:nth-child(2) { padding-left: 16px; }
.comparison[data-size="xs"] .featureName { padding-inline: 16px; }
.comparison[data-size="xs"] .intro, .comparison[data-size="xs"] .filter, .comparison[data-size="xs"] .footer { padding-inline: 16px; }
@media (prefers-reduced-motion: reduce) {
.planCell, .featureValue { transition: none; }
}
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
plan-comparison.tsxplan-comparison.tsxplan-comparison.tsxtsx
"use client";
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { animate, AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { Button } from "../../button/button";
import SegmentedControl from "../../segmented-control/segmented-control";
import { Switch } from "../../switch/switch";
import { motionTokens } from "../../lib/motion-tokens";
import styles from "./plan-comparison.module.css";
import { UiIcon } from "../../theme/ui-icon";
type Plan = "team" | "studio";
type Billing = "monthly" | "yearly";
type Feature = { label: string; team: string; studio: string; shared?: boolean };
const features: Feature[] = [
{ label: "진행 중인 프로젝트", team: "무제한", studio: "무제한", shared: true },
{ label: "공유 작업 공간", team: "1개", studio: "무제한" },
{ label: "외부 검토자", team: "프로젝트당 5명", studio: "무제한" },
{ label: "버전 기록", team: "30일", studio: "무제한" },
{ label: "승인 흐름", team: "포함되지 않아요", studio: "포함돼요" },
{ label: "맞춤 역할", team: "포함되지 않아요", studio: "포함돼요" },
{ label: "원본 내보내기", team: "포함돼요", studio: "포함돼요", shared: true },
{ label: "지원", team: "이메일", studio: "우선 이메일" },
];
const pricing: Record<Plan, Record<Billing, number>> = {
team: { monthly: 15000, yearly: 12000 },
studio: { monthly: 32000, yearly: 25000 },
};
function Value({ text }: { text: string }) {
if (text === "포함돼요") return <span className={styles.included}><UiIcon iconRole="check" size={16} strokeWidth={1.75} aria-hidden="true" />포함돼요</span>;
return <span className={text === "포함되지 않아요" ? styles.unavailable : undefined}>{text}</span>;
}
function AnimatedNumber({ value }: { value: number }) {
const reduce = useReducedMotion();
const [displayValue, setDisplayValue] = useState(value);
const previousValue = useRef(value);
useEffect(() => {
if (reduce) {
previousValue.current = value;
return;
}
const controls = animate(previousValue.current, value, {
duration: motionTokens.duration.standard,
ease: [...motionTokens.ease.enter],
onUpdate: (latest) => setDisplayValue(Math.round(latest)),
});
previousValue.current = value;
return () => controls.stop();
}, [reduce, value]);
return <>{(reduce ? value : displayValue).toLocaleString("ko-KR")}</>;
}
export function PlanComparison() {
const id = useId();
const reduce = useReducedMotion();
const [billing, setBilling] = useState<Billing>("monthly");
const [differencesOnly, setDifferencesOnly] = useState(false);
const [selected, setSelected] = useState<Plan | null>(null);
const rootRef = useRef<HTMLElement>(null);
const [size, setSize] = useState<"lg" | "md" | "sm" | "xs">("lg");
useLayoutEffect(() => {
const node = rootRef.current;
if (!node) return;
const measure = (width: number) => setSize(width <= 340 ? "xs" : width <= 540 ? "sm" : width <= 720 ? "md" : "lg");
measure(node.getBoundingClientRect().width);
const observer = new ResizeObserver(([entry]) => { if (entry) measure(entry.contentRect.width); });
observer.observe(node);
return () => observer.disconnect();
}, []);
const visibleFeatures = differencesOnly ? features.filter((feature) => !feature.shared) : features;
return (
<section ref={rootRef} className={styles.comparison} data-size={size} aria-labelledby={`${id}-title`}>
<div className={styles.intro}>
<div><h2 id={`${id}-title`}>일하는 방식에 맞는 플랜</h2><p>플랜마다 달라지는 내용만 모았어요. 결제 주기를 바꿔 요금을 확인해 보세요.</p></div>
<div className={styles.billing}><SegmentedControl label="결제 주기" value={billing} onValueChange={(value) => setBilling(value as Billing)} options={[{ value: "monthly", label: "월간" }, { value: "yearly", label: "연간" }]} /><small>{billing === "yearly" ? "연간 결제하면 최대 22% 절약돼요" : "매달 결제해요"}</small></div>
</div>
<div className={styles.filter}><div><h3>플랜 비교</h3><span className={styles.featureCount}>기능 {features.length}개 중 <AnimatedNumber value={visibleFeatures.length} />개</span></div><Switch checked={differencesOnly} onCheckedChange={setDifferencesOnly} label="차이점만 보기" /></div>
<div className={styles.matrix} role="table" aria-label="팀 플랜과 스튜디오 플랜 비교">
<div className={styles.planHeader} role="row">
<div className={styles.featureHeader} role="columnheader">달라지는 점</div>
{(["team", "studio"] as const).map((plan) => (
<div key={plan} className={`${styles.planCell} ${selected === plan ? styles.planSelected : ""}`} role="columnheader" aria-label={`${plan === "team" ? "팀" : "스튜디오"} 플랜`}>
<div className={styles.planTop}><h3>{plan === "team" ? "팀" : "스튜디오"}</h3><span>{plan === "team" ? "작은 팀을 위한 플랜" : "여러 팀이 함께 쓰는 플랜"}</span></div>
<div className={styles.price}><span className={styles.priceValue} aria-live="polite" aria-atomic="true">₩<AnimatedNumber value={pricing[plan][billing]} /></span><span className={styles.pricePeriod}>사용자 1명당 / 월</span></div>
<Button type="button" variant={selected === plan ? "primary" : "secondary"} className={styles.selectButton} aria-pressed={selected === plan} onClick={() => setSelected(current => current === plan ? null : plan)}>{selected === plan ? "선택했어요" : `${plan === "team" ? "팀" : "스튜디오"} 플랜 선택하기`}</Button>
{selected === plan && <motion.span className={styles.selectedRail} layoutId={`${id}-selected-rail`} transition={reduce ? { duration: 0 } : motionTokens.spring.responsive} aria-hidden="true" />}
</div>
))}
</div>
<div className={styles.rows} role="rowgroup">
<AnimatePresence initial={false}>
{visibleFeatures.map((feature) => (
<motion.div key={feature.label} className={styles.featureRow} role="row" initial={reduce ? false : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={reduce ? undefined : { height: 0, opacity: 0, transition: { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } } }} transition={reduce ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: motionTokens.delay.micro } }}>
<div className={styles.featureName} role="rowheader">{feature.label}</div>
<div className={`${styles.featureValue} ${selected === "team" ? styles.valueSelected : ""}`} role="cell"><span className={styles.mobilePlan}>팀</span><Value text={feature.team} /></div>
<div className={`${styles.featureValue} ${selected === "studio" ? styles.valueSelected : ""}`} role="cell"><span className={styles.mobilePlan}>스튜디오</span><Value text={feature.studio} /></div>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
<div className={styles.footer}><span>요금은 사용자 1명당 원화 기준이에요. 연간 플랜은 1년 단위로 결제해요.</span><p className={styles.srOnly} role="status" aria-live="polite">{selected ? `${selected === "team" ? "팀" : "스튜디오"} 플랜을 선택했어요` : ""}</p></div>
</section>
);
}
export default PlanComparison;
Used items
- ButtonComponent
The base button with press feedback, sound and several variants.
- Segmented controlComponent
A row of mutually exclusive options with a highlight that moves to the chosen segment.
- SwitchComponent
A toggle for on and off values whose thumb stretches while pressed.