Block
CTA section
A call to action section in centered, split, and dismissible banner variants.
- #arc
- #block
- #cta
- #marketing
- #landing
- #banner
- #section
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/cta-section.jsonFiles
components/arc/blocks/cta-section/cta-section-data.tscomponents/arc/blocks/cta-section/cta-section.module.csscomponents/arc/blocks/cta-section/cta-section.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdowncta-section-data.tscta-section-data.tscta-section-data.tsts
import { avatar, type PersonId } from "../../lib/media";
export interface CtaAction {
label: string;
/** Renders a link. */
href?: string;
onClick?: () => void;
/** With no href or onClick, the button confirms in place with this label, which suits previews. */
confirmedLabel?: string;
}
export const ctaCopy = {
centered: {
title: "몇 분 만에 시작해요",
description: "워크스페이스를 만들고 팀을 초대하면 이번 주에 첫 프로젝트를 내보낼 수 있어요. 14일 동안 무료예요.",
primary: { label: "무료로 시작하기", confirmedLabel: "체험을 시작했어요" },
secondary: { label: "영업팀과 상담하기", confirmedLabel: "메일로 알려 드릴게요" },
note: "신용카드는 필요 없어요",
},
split: {
title: "점심 전에 팀 설정이 끝나요",
description: "프로젝트를 가져오고 모두를 초대해서 지금 쓰던 방식 그대로 일해요.",
primary: { label: "워크스페이스 만들기", confirmedLabel: "워크스페이스를 만들었어요" },
secondary: { label: "데모 신청하기", confirmedLabel: "데모를 신청했어요" },
points: ["어떤 트래커에서든 한 번에 가져와요", "SSO와 감사 로그가 포함돼요", "설정에서 언제든 해지할 수 있어요"],
},
banner: {
title: "워크플로가 나왔어요",
description: "코드 없이 팀 간 인수인계를 자동화해요.",
primary: { label: "더 알아보기", confirmedLabel: "열었어요" },
},
} satisfies Record<string, { title: string; description: string; primary: CtaAction; secondary?: CtaAction; note?: string; points?: string[] }>;
/** Faces for the social proof line in the centered variant. */
export const ctaFaces = (["sofia-ramirez", "nathan-cole", "chloe-nguyen", "tyler-hayes"] as PersonId[]).map(id => avatar(id));
/** Sample content for the split variant's setup card. */
export const ctaSetup = {
workspace: "한울스튜디오",
steps: ["워크스페이스 만들기", "이슈 214개 가져오기", "팀원 4명 초대하기"],
faces: (["emma-collins", "marcus-johnson", "jasmine-brooks", "daniel-kim"] as PersonId[]).map(id => avatar(id)),
};
cta-section.module.csscta-section.module.csscta-section.module.csscss
.cta { container: cta / inline-size; background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.cta *, .cta *::before, .cta *::after { box-sizing: border-box; }
.title { margin: 0; font-family: var(--font-display); font-size: clamp(1.875rem, 1rem + 3.4cqi, 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-lg); line-height: var(--leading-body); text-wrap: pretty; }
.actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
/* Actions: the arrow leans forward on hover; links match the button shapes. */
.arrow { transition: transform var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .action:hover .arrow, .linkAction:hover .arrow { transform: translateX(3px); } }
.linkAction { display: inline-flex; height: var(--control-height-lg); align-items: center; justify-content: center; gap: 8px; border: 1px solid transparent; border-radius: var(--radius-control); padding: 0 20px; font-size: var(--text-sm); font-weight: 500; text-decoration: none; transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.linkAction[data-size="sm"] { height: var(--control-height-sm); padding: 0 14px; }
.linkAction[data-variant="primary"] { background: var(--accent); color: var(--accent-foreground); }
.linkAction[data-variant="secondary"] { border-color: var(--border); background: var(--surface); color: var(--foreground); }
.linkAction:active { transform: scale(.97); }
/* Centered: a closing section on one quiet surface. */
.centeredWrap { max-width: 1120px; margin: 0 auto; padding: var(--space-16) var(--space-8); }
.centered { display: grid; justify-items: center; gap: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-surface); padding: var(--space-24) var(--space-8); background: color-mix(in oklab, var(--surface-muted) 45%, var(--surface)); text-align: center; }
.centered .title { max-width: 18ch; }
.centered .description { max-width: 34rem; }
.centered .actions { justify-content: center; margin-top: var(--space-4); }
.note { display: inline-flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: var(--space-3); margin: var(--space-3) 0 0; color: var(--text-muted); font-size: var(--text-sm); }
.faces { display: flex; padding-left: 6px; }
.faces img { display: block; width: 26px; height: 26px; margin-left: -6px; border: 2px solid var(--surface); border-radius: 50%; object-fit: cover; }
/* Split: copy beside a setup card that completes itself. */
.split { display: grid; max-width: 1120px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; gap: var(--space-16); margin: 0 auto; padding: var(--space-24) var(--space-8); }
.splitCopy { display: grid; justify-items: start; gap: var(--space-4); }
.splitCopy .actions { margin-top: var(--space-4); }
.points { display: grid; gap: var(--space-2); margin: var(--space-2) 0 0; padding: 0; list-style: none; }
.points li { display: flex; align-items: center; gap: var(--space-3); color: var(--text-secondary); font-size: var(--text-sm); }
.points svg { flex: none; color: var(--success); }
.stage { display: grid; min-height: 380px; place-items: center; border: 1px solid var(--border); border-radius: var(--radius-surface); padding: var(--space-8); background: color-mix(in oklab, var(--surface-muted) 55%, var(--surface)); }
.setup { width: min(100%, 360px); border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-raised); overflow: hidden; }
.setupHead { display: flex; align-items: center; gap: 10px; padding: 16px 18px 14px; }
.workspaceMark { display: grid; width: 28px; height: 28px; flex: none; place-items: center; border-radius: 8px; background: var(--foreground); color: var(--background); font-size: var(--text-sm); font-weight: 500; }
.setupTitle { font-size: var(--text-sm); font-weight: 500; }
.setupStatus { display: inline-grid; margin-left: auto; color: var(--text-muted); font-size: var(--text-xs); justify-items: end; }
.setupStatus > span { grid-area: 1 / 1; opacity: 0; transform: translateY(4px); filter: blur(2px); transition: opacity var(--duration-fast) var(--ease-standard), transform var(--duration-standard) var(--ease-enter), filter var(--duration-fast) var(--ease-standard); }
.setupStatus > span[data-shown] { opacity: 1; transform: none; filter: blur(0); }
.setup[data-done] .setupStatus > span:last-child { color: var(--success); font-weight: 500; }
.progress { height: 2px; margin: 0 18px; border-radius: 1px; background: var(--border); overflow: hidden; }
.progress > span { display: block; height: 100%; background: var(--success); transform-origin: left; transition: transform var(--duration-considered) var(--ease-in-out); }
.steps { display: grid; gap: 2px; margin: 0; padding: 10px 8px 12px; list-style: none; }
.step { display: flex; min-height: 44px; align-items: center; gap: 12px; border-radius: 14px; padding: 0 10px; color: var(--text-muted); font-size: var(--text-sm); transition: color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard); }
.step[data-active] { background: var(--surface-muted); color: var(--foreground); }
.step[data-done] { color: var(--foreground); }
.stepMark { position: relative; display: grid; width: 20px; height: 20px; flex: none; place-items: center; border: 1.5px dashed var(--border-strong); border-radius: 50%; color: transparent; transition: border-color var(--duration-standard) var(--ease-standard), background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
.stepMark svg { transform: scale(.5); opacity: 0; transition: transform var(--duration-spring) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }
.step[data-active] .stepMark { border-style: solid; border-color: var(--text-muted); }
.step[data-done] .stepMark { border-style: solid; border-color: var(--success); background: var(--success); color: var(--background); }
.step[data-done] .stepMark svg { transform: scale(1); opacity: 1; }
.stepLabel { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stepFaces { display: flex; margin-left: auto; padding-left: 6px; }
.stepFaces img { display: block; width: 22px; height: 22px; margin-left: -6px; border: 2px solid var(--surface-raised); border-radius: 50%; object-fit: cover; opacity: 0; transform: translateX(-6px) scale(.9); transition: opacity var(--duration-standard) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
.step[data-done] .stepFaces img, .step[data-active] .stepFaces img { opacity: 1; transform: none; }
/* Banner: one line that closes its gap when dismissed. */
.bannerWrap { overflow: hidden; }
.bannerPad { max-width: calc(1120px + 2 * var(--space-8)); margin: 0 auto; padding: var(--space-6) var(--space-8); }
.banner { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); border: 1px solid var(--border); border-radius: 20px; padding: 10px 10px 10px 20px; background: var(--surface); }
.bannerText { display: flex; min-width: 0; flex-wrap: wrap; align-items: baseline; gap: 2px var(--space-2); margin: 0; font-size: var(--text-sm); line-height: var(--leading-body); }
.bannerText strong { font-weight: 500; }
.bannerText span { color: var(--text-secondary); }
.bannerActions { display: flex; flex: none; align-items: center; gap: var(--space-1); }
.dismiss { display: grid; width: var(--control-height-sm); height: var(--control-height-sm); place-items: center; border: 0; border-radius: 50%; background: none; color: var(--text-muted); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .dismiss:hover { background: var(--surface-muted); color: var(--foreground); } }
.dismiss:active { transform: scale(.94); }
@container cta (max-width: 860px) {
.split { grid-template-columns: minmax(0, 1fr); gap: var(--space-10); padding: var(--space-16) var(--space-8); }
.stage { min-height: 320px; }
}
@container cta (max-width: 560px) {
.centeredWrap { padding: var(--space-10) var(--space-4); }
.centered { padding: var(--space-16) var(--space-5); border-radius: 28px; }
.split { padding: var(--space-12) var(--space-4); }
.stage { padding: var(--space-5); border-radius: 28px; }
.description { font-size: var(--text-base); }
.actions { width: 100%; flex-direction: column; }
.actions > * { width: 100%; }
.bannerPad { padding: var(--space-4); }
.banner { align-items: flex-start; flex-direction: column; padding: 16px; }
.bannerActions { width: 100%; justify-content: space-between; }
}
.preview { display: grid; width: 100%; justify-items: center; gap: var(--space-4); }
.frame { width: 100%; overflow: hidden; border: 1px solid var(--border); border-radius: 20px; background: var(--background); }
.restore { display: grid; min-height: 104px; place-items: center; padding: var(--space-6); }
@media (prefers-reduced-motion: reduce) {
.arrow, .setupStatus > span, .progress > span, .step, .stepMark, .stepMark svg, .stepFaces img { transition: none; }
}
cta-section.tsxcta-section.tsxcta-section.tsxtsx
"use client";
import { forwardRef, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { Button } from "../../button/button";
import SegmentedControl from "../../segmented-control/segmented-control";
import { motionTokens } from "../../lib/motion-tokens";
import { ctaCopy, ctaFaces, ctaSetup, type CtaAction } from "./cta-section-data";
import styles from "./cta-section.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";
export type { CtaAction } from "./cta-section-data";
export type CtaVariant = "centered" | "split" | "banner";
export interface CtaSectionProps {
/** `centered` is a closing section, `split` sits beside a product visual, `banner` is one dismissible line. */
variant?: CtaVariant;
title?: string;
description?: string;
primaryAction?: CtaAction;
/** Not shown in the banner variant. Pass null to hide it. */
secondaryAction?: CtaAction | null;
/** Small print under the actions in the centered variant, such as "No credit card required". */
note?: string;
/** Faces beside the note in the centered variant. Pass an empty array to hide them. */
faces?: string[];
/** Short benefit lines in the split variant. */
points?: string[];
/** Replaces the split variant's setup card. */
visual?: ReactNode;
/** Banner only: shows a dismiss button and calls this after it closes. */
onDismiss?: () => void;
className?: string;
}
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
/** A link, a callback, or (with neither) a button that confirms in place, so previews always answer a click. */
function Action({ action, variant, size = "lg", arrow }: { action: CtaAction; variant: "primary" | "secondary"; size?: "sm" | "md" | "lg"; arrow?: boolean }) {
const [confirmed, setConfirmed] = useState(false);
if (action.href) {
return <a className={styles.linkAction} data-variant={variant} data-size={size} href={action.href} onClick={() => playUiSound("click")}>
{action.label}{arrow && <UiIcon iconRole="arrow-right" className={styles.arrow} size={16} strokeWidth={2} aria-hidden="true" />}
</a>;
}
const simulated = !action.onClick;
return <Button
className={styles.action}
variant={variant}
size={size}
aria-live={simulated ? "polite" : undefined}
onClick={() => { if (action.onClick) action.onClick(); else setConfirmed(value => !value); }}
>
{confirmed
? <><UiIcon iconRole="check" size={16} strokeWidth={2.25} aria-hidden="true" />{action.confirmedLabel ?? action.label}</>
: <>{action.label}{arrow && <UiIcon iconRole="arrow-right" className={styles.arrow} size={16} strokeWidth={2} aria-hidden="true" />}</>}
</Button>;
}
/** A setup card that ticks through its steps once it scrolls into view. */
function SetupVisual({ reduced }: { reduced: boolean }) {
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref, { once: true, amount: .5 });
const total = ctaSetup.steps.length;
const [step, setStep] = useState(0);
const shown = reduced ? total : step;
useEffect(() => {
if (!inView || reduced) return;
const timers = ctaSetup.steps.map((_, index) => window.setTimeout(() => setStep(index + 1), 520 + index * 620));
return () => timers.forEach(window.clearTimeout);
}, [inView, reduced]);
return <div ref={ref} className={styles.setup} aria-hidden="true" data-done={shown === total ? "" : undefined}>
<div className={styles.setupHead}>
<span className={styles.workspaceMark}>{ctaSetup.workspace.charAt(0)}</span>
<span className={styles.setupTitle}>{ctaSetup.workspace}</span>
<span className={styles.setupStatus}>
<span data-shown={shown < total ? "" : undefined}>설정하는 중이에요</span>
<span data-shown={shown === total ? "" : undefined}>준비됐어요</span>
</span>
</div>
<div className={styles.progress}><span style={{ transform: `scaleX(${shown / total})` }} /></div>
<ol className={styles.steps}>
{ctaSetup.steps.map((label, index) => {
const done = index < shown;
return <li key={label} className={styles.step} data-done={done ? "" : undefined} data-active={index === shown ? "" : undefined}>
<span className={styles.stepMark}><UiIcon iconRole="check" size={12} strokeWidth={2.75} /></span>
<span className={styles.stepLabel}>{label}</span>
{index === total - 1 && <span className={styles.stepFaces}>
{/* eslint-disable-next-line @next/next/no-img-element */}
{ctaSetup.faces.map((src, face) => <img key={src} src={src} alt="" width={22} height={22} style={{ transitionDelay: `${face * 60}ms` }} />)}
</span>}
</li>;
})}
</ol>
</div>;
}
function Faces({ faces }: { faces: string[] }) {
if (!faces.length) return null;
return <span className={styles.faces} aria-hidden="true">
{/* eslint-disable-next-line @next/next/no-img-element */}
{faces.slice(0, 4).map(src => <img key={src} src={src} alt="" width={26} height={26} />)}
</span>;
}
/**
* A call to action in three shapes: a centered closing section, a split layout beside a setup card that completes
* itself in view, and a compact banner that closes its gap when dismissed.
*/
export const CtaSection = forwardRef<HTMLElement, CtaSectionProps>(function CtaSection({
variant = "centered",
title,
description,
primaryAction,
secondaryAction,
note,
faces = ctaFaces,
points,
visual,
onDismiss,
className,
}, ref) {
const id = useId();
const reduced = !!useReducedMotion();
const [open, setOpen] = useState(true);
const copy = ctaCopy[variant];
const heading = title ?? copy.title;
const text = description ?? copy.description;
const primary = primaryAction ?? copy.primary;
const secondary = secondaryAction === null ? null : secondaryAction ?? ("secondary" in copy ? copy.secondary : undefined);
const classes = [styles.cta, className].filter(Boolean).join(" ");
if (variant === "banner") {
return <section ref={ref} className={classes} data-variant="banner" aria-labelledby={`${id}-title`}>
<AnimatePresence initial={false} onExitComplete={onDismiss}>
{open && <motion.div
key="banner"
className={styles.bannerWrap}
exit={reduced ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, height: 0, transition: { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast, ease: standard } } }}
>
<div className={styles.bannerPad}><div className={styles.banner}>
<p className={styles.bannerText}>
<strong id={`${id}-title`}>{heading}</strong>
<span>{text}</span>
</p>
<div className={styles.bannerActions}>
<Action action={primary} variant="primary" size="sm" arrow />
{onDismiss !== undefined && <button type="button" className={styles.dismiss} aria-label="닫기" onClick={() => setOpen(false)}>
<UiIcon iconRole="close" size={16} strokeWidth={1.75} aria-hidden="true" />
</button>}
</div>
</div></div>
</motion.div>}
</AnimatePresence>
</section>;
}
if (variant === "split") {
const list = points ?? ctaCopy.split.points;
return <section ref={ref} className={classes} data-variant="split" aria-labelledby={`${id}-title`}>
<div className={styles.split}>
<div className={styles.splitCopy}>
<h2 id={`${id}-title`} className={styles.title}>{heading}</h2>
<p className={styles.description}>{text}</p>
{list.length > 0 && <ul className={styles.points}>
{list.map(point => <li key={point}><UiIcon iconRole="check" size={16} strokeWidth={2} aria-hidden="true" />{point}</li>)}
</ul>}
<div className={styles.actions}>
<Action action={primary} variant="primary" arrow />
{secondary && <Action action={secondary} variant="secondary" />}
</div>
</div>
<motion.div
className={styles.stage}
initial={reduced ? false : { opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: .35 }}
transition={{ duration: motionTokens.duration.considered, ease: enter }}
>
{visual ?? <SetupVisual reduced={reduced} />}
</motion.div>
</div>
</section>;
}
const small = note ?? ctaCopy.centered.note;
return <section ref={ref} className={classes} data-variant="centered" aria-labelledby={`${id}-title`}>
<div className={styles.centeredWrap}>
<div className={styles.centered}>
<h2 id={`${id}-title`} className={styles.title}>{heading}</h2>
<p className={styles.description}>{text}</p>
<div className={styles.actions}>
<Action action={primary} variant="primary" arrow />
{secondary && <Action action={secondary} variant="secondary" />}
</div>
{(small || faces.length > 0) && <p className={styles.note}><Faces faces={faces} />{small}</p>}
</div>
</div>
</section>;
});
CtaSection.displayName = "CtaSection";
const variantOptions = [{ value: "centered", label: "가운데" }, { value: "split", label: "분할" }, { value: "banner", label: "배너" }];
/** Springs the frame to its content's height after a discrete change (a new layout, a dismissed banner), so the preview never snaps.
* Other resizes, such as the banner's own collapse or a parent reflow, follow at once so nothing lags. */
function useFrameHeight(key: string, reduced: boolean) {
const content = useRef<HTMLDivElement>(null);
const height = useMotionValue<number | "auto">("auto");
const lastKey = useRef(key), armedUntil = useRef(0);
useLayoutEffect(() => {
if (lastKey.current === key) return;
lastKey.current = key;
armedUntil.current = performance.now() + 700;
}, [key]);
useEffect(() => {
const node = content.current;
if (!node || typeof ResizeObserver === "undefined") return;
let measured = false;
const observer = new ResizeObserver(() => {
const next = node.offsetHeight;
if (!measured || reduced || performance.now() > armedUntil.current) { measured = next > 0; height.jump(next || "auto"); return; }
animate(height, next, motionTokens.spring.smooth);
});
observer.observe(node);
return () => observer.disconnect();
}, [height, reduced]);
return { content, height };
}
/** Preview: all three shapes. Buttons confirm in place; nothing is sent. */
export function CtaSectionBlock() {
const reduced = !!useReducedMotion();
const [variant, setVariant] = useState<CtaVariant>("centered");
const [dismissed, setDismissed] = useState(false);
const showRestore = variant === "banner" && dismissed;
const view = showRestore ? "restore" : variant;
const { content, height } = useFrameHeight(view, reduced);
// The first layout appears as is; later layouts fade in while the frame springs to their height.
const [changed, setChanged] = useState(false);
return <div className={styles.preview}>
<SegmentedControl label="행동 유도 레이아웃" options={variantOptions} value={variant} onValueChange={next => { setChanged(true); setVariant(next as CtaVariant); setDismissed(false); }} />
<motion.div className={styles.frame} style={{ height }}>
<div ref={content}>
<motion.div key={view} initial={reduced || !changed ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: motionTokens.duration.standard, ease: standard }}>
{showRestore
? <div className={styles.restore}><Button variant="secondary" size="sm" onClick={() => setDismissed(false)}>배너 다시 보기</Button></div>
: <CtaSection variant={variant} onDismiss={variant === "banner" ? () => { setChanged(true); setDismissed(true); } : undefined} />}
</motion.div>
</div>
</motion.div>
</div>;
}
export default CtaSectionBlock;
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.