Block
Site footer
A site footer in columns, minimal, and large logo variants with an optional newsletter.
- #arc
- #block
- #footer
- #navigation
- #links
- #newsletter
- #site
- #illustration
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/site-footer.jsonFiles
components/arc/blocks/site-footer/site-footer.module.csscomponents/arc/blocks/site-footer/site-footer.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
Registry items it pulls in
Source
View as Markdownsite-footer.module.csssite-footer.module.csssite-footer.module.csscss
.footer { --footer-pad: var(--space-6); position: relative; container: site-footer / inline-size; overflow: hidden; border-top: 1px solid var(--border); padding: var(--space-12) var(--footer-pad) var(--space-6); background: var(--background); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.top { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: var(--space-12); }
.intro { display: grid; align-content: start; gap: var(--space-3); min-width: 0; }
.brandRow { display: inline-flex; align-items: center; gap: var(--space-2); }
.brandLogo { display: inline-flex; width: fit-content; color: var(--text-secondary); line-height: 0; }
/* The footer mark is one quiet tone (the footer text color), not the two brand colors. */
.brandLogo path { fill: currentColor; transition: fill var(--duration-standard, .3s) var(--ease-standard, ease); }
/* On hover or keyboard focus the brand colors come back: DEV blue (the first three shapes), S7UDIO yellow. */
@media (hover: hover) and (pointer: fine) {
.brandLogo:hover path { fill: #fbbf24; }
.brandLogo:hover path:nth-child(-n+3) { fill: #2563eb; }
}
.brandLogo:focus-visible path { fill: #fbbf24; }
.brandLogo:focus-visible path:nth-child(-n+3) { fill: #2563eb; }
@media (prefers-reduced-motion: reduce) { .brandLogo path { transition: none; } }
.brand { border: 0; padding: 0; background: none; color: var(--foreground); font: inherit; font-size: var(--text-base); font-weight: 500; text-decoration: none; cursor: pointer; }
.tagline { max-width: 320px; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.columns { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-6); }
.column h2 { margin: 0 0 var(--space-3); font-size: var(--text-sm); font-weight: 500; }
.column ul, .inline { margin: 0; padding: 0; list-style: none; }
.column ul { display: grid; gap: var(--space-2); }
.link { display: inline-flex; align-items: center; gap: 3px; border: 0; padding: 2px 0; background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); text-align: left; text-decoration: none; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard); }
.link:active, .link:focus-visible { color: var(--foreground); }
/* A social profile: just its glyph in a round button (the label is read by screen readers). Hover, press and keyboard focus each show. */
.iconLink { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 0; border-radius: 50%; padding: 0; background: none; color: var(--text-secondary); text-decoration: none; cursor: pointer; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); -webkit-tap-highlight-color: transparent; }
.iconGlyph { display: grid; place-items: center; line-height: 0; }
.iconGlyph svg { width: 18px; height: 18px; fill: currentColor; }
.iconLink:active { background: var(--surface-muted); color: var(--foreground); transform: scale(.92); }
.iconLink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; color: var(--foreground); }
@media (hover: hover) and (pointer: fine) { .iconLink:hover { background: var(--surface-muted); color: var(--foreground); } }
@media (prefers-reduced-motion: reduce) { .iconLink { transition: none; } .iconLink:active { transform: none; } }
.external { color: var(--text-muted); transition: transform var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) {
.link:hover { color: var(--foreground); }
.link:hover .external { transform: translate(1px, -1px); }
}
/* Newsletter: the field and the button sit side by side at one height. The field fills with the accent on focus and with danger on error, like Input. */
.newsletter { display: grid; gap: var(--space-2); max-width: 400px; margin-top: var(--space-5); }
.newsletterTitle { margin: 0; font-size: var(--text-sm); font-weight: var(--label-weight); }
.newsletterRow { display: flex; align-items: stretch; gap: var(--space-2); }
.newsletterInput { width: 100%; min-width: 0; flex: 1; min-height: var(--control-height-md); border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 var(--space-3); background: var(--surface); color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.newsletterInput::placeholder { color: var(--text-muted); }
@media (hover: hover) and (pointer: fine) { .newsletterInput:hover:not([aria-invalid="true"], :focus-visible, :read-only) { border-color: var(--foreground); } }
.newsletterInput:focus-visible { border-color: var(--accent); background: var(--accent); color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.newsletterInput:focus-visible::placeholder { color: var(--accent-foreground-muted); }
.newsletterInput[aria-invalid="true"] { border-color: var(--danger); background: var(--danger); color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.newsletterInput[aria-invalid="true"]::placeholder { color: var(--accent-foreground-muted); }
.newsletterRow[data-done] .newsletterInput { color: var(--text-secondary); }
.newsletterButton { flex: none; }
.messageSlot { position: relative; min-height: 20px; }
.message { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 20px; }
.message[data-tone="error"] { color: var(--danger); }
.message[data-tone="success"] { color: var(--success); }
.bottom { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-6); margin-top: var(--space-12); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }
.bottomStart, .bottomEnd { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-5); }
.inline { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-4); }
.copyright { color: var(--text-muted); font-size: var(--text-sm); }
.status { display: inline-flex; align-items: center; gap: var(--space-2); border: 0; padding: 2px 0; background: none; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); text-decoration: none; cursor: pointer; }
.statusDot { --tone: var(--success); width: 7px; height: 7px; border-radius: 50%; background: var(--tone); box-shadow: 0 0 0 3px color-mix(in oklab, var(--tone) 18%, transparent); }
.status[data-tone="warning"] .statusDot { --tone: var(--warning); }
.status[data-tone="danger"] .statusDot { --tone: var(--danger); }
@media (hover: hover) and (pointer: fine) { .status:hover { color: var(--foreground); } }
/* Minimal: two quiet rows. */
.minimal { padding-block: var(--space-8) var(--space-6); }
.minimalRow { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3) var(--space-6); }
.minimalRow + .minimalRow { margin-top: var(--space-6); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }
/* Logo: the brand mark closes the page, cropped by the bottom edge and fading out. */
.logo { padding-bottom: 0; }
.markStage { display: flex; height: clamp(110px, 22cqw, 210px); justify-content: center; overflow: hidden; margin: var(--space-10) calc(var(--footer-pad) * -1) 0; -webkit-mask-image: linear-gradient(to bottom, #000 18%, transparent 96%); mask-image: linear-gradient(to bottom, #000 18%, transparent 96%); pointer-events: none; }
.bigMark { display: block; flex: none; width: clamp(180px, 40cqw, 420px); height: auto; aspect-ratio: 1; }
@container site-footer (max-width: 760px) {
.top { grid-template-columns: minmax(0, 1fr); gap: var(--space-10); }
}
@container site-footer (max-width: 520px) {
.columns { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-8) var(--space-4); }
.newsletter { max-width: none; }
.bottom { flex-direction: column; align-items: flex-start; }
}
.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); }
.frameBody { overflow: hidden; }
.frame .footer { border-top: 0; }
.previewNote { min-height: 20px; margin: 0; color: var(--text-muted); font-size: var(--text-xs); }
@media (prefers-reduced-motion: reduce) {
.link, .external, .newsletterRow { transition: none; }
}
/* The footer band stands on the bottom edge and runs past the footer padding, so the shapes touch both sides. */
.withBand { padding-bottom: 0; }
.footer .band { width: calc(100% + var(--footer-pad) * 2); max-width: none; margin: var(--space-10) calc(var(--footer-pad) * -1) 0; }
site-footer.tsxsite-footer.tsxsite-footer.tsxtsx
"use client";
import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
import type { ElementType, FormEvent, HTMLAttributes, ReactNode } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { HugeiconsIcon } from "@hugeicons/react";
import { ArrowUpRight01Icon } from "@hugeicons/core-free-icons";
import { Button } from "../../button/button";
import { Devs7udioLogo } from "../../lib/brand-logo";
import { BRAND_NAME, Devs7udioMark } from "../../lib/brand-mark";
import SegmentedControl from "../../segmented-control/segmented-control";
import { motionTokens } from "../../lib/motion-tokens";
import { Illustration } from "../../illustration/illustration";
import styles from "./site-footer.module.css";
import { UiIcon } from "../../theme/ui-icon";
export type SiteFooterVariant = "columns" | "minimal" | "logo";
export interface SiteFooterLink {
label: string;
href?: string;
/** Opens in a new tab and shows an outward arrow. */
external?: boolean;
/** The language of the linked page, when it differs from this one. */
hrefLang?: string;
/** A file or feed rather than a page: always a plain anchor, never `linkAs`. */
document?: boolean;
/** A glyph shown in place of the text, as a round icon button (a social profile). The label stays as the accessible name, and the outward arrow is left out. */
icon?: ReactNode;
}
/** Names of the footer's landmarks and lists, for pages in another language. */
export interface SiteFooterLabels {
/** The link columns. Default "푸터". */
nav?: string;
/** The legal links beside the copyright. Default "약관 및 정책". */
legal?: string;
/** The links at the end of the bottom row. Default "소셜 링크". */
socials?: string;
}
export interface SiteFooterColumn {
title: string;
links: SiteFooterLink[];
}
export interface SiteFooterNewsletter {
title?: string;
description?: string;
placeholder?: string;
/** Called with a valid address. Throw or reject to show an error; resolve to show the subscribed state. */
onSubscribe?: (email: string) => void | Promise<void>;
}
export interface SiteFooterProps extends Omit<HTMLAttributes<HTMLElement>, "children"> {
/** `columns` pairs a newsletter with link columns, `minimal` is one quiet row, `logo` ends in a large fading brand mark. */
variant?: SiteFooterVariant;
brand?: { name: string; href?: string; mark?: ReactNode };
/** One short line under the brand. */
tagline?: string;
columns?: SiteFooterColumn[];
/** Links for the minimal variant. Defaults to the first link of each column. */
links?: SiteFooterLink[];
/** Small links beside the copyright, such as Privacy and Terms. */
legal?: SiteFooterLink[];
socials?: SiteFooterLink[];
/** Newsletter signup. Pass null to hide it. */
newsletter?: SiteFooterNewsletter | null;
/** A system status link. Pass null to hide it. */
status?: { label: string; tone?: "success" | "warning" | "danger"; href?: string } | null;
/** Year in the copyright line. */
year?: number;
/** Called for every link that is pressed. Links without an href render as buttons and only call this. */
onNavigate?: (link: SiteFooterLink) => void;
/** Renders same-site links (not external, not documents) with this component, such as a router's Link. Default `a`. */
linkAs?: ElementType;
labels?: SiteFooterLabels;
/** Draws the footer band, a full-width skyline of shapes, along the bottom edge of the columns and minimal layouts. The theme's own art is used when it has one. */
illustration?: boolean;
/** Replaces the "© year brand" text at the start of the bottom row, for credits beside the copyright. */
copyright?: ReactNode;
/** Extra rows under the bottom row, such as business details. */
children?: ReactNode;
}
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
export const siteFooterExampleColumns: SiteFooterColumn[] = [
{ title: "제품", links: [{ label: "컴포넌트" }, { label: "블록" }, { label: "템플릿" }, { label: "요금" }] },
{ title: "자료", links: [{ label: "문서" }, { label: "변경 기록" }, { label: "가이드" }, { label: "Figma 키트", external: true }] },
{ title: "회사", links: [{ label: "소개" }, { label: "고객 사례" }, { label: "채용" }, { label: "문의하기" }] },
];
const exampleLegal: SiteFooterLink[] = [{ label: "개인정보 처리방침" }, { label: "이용약관" }, { label: "라이선스" }];
const exampleSocials: SiteFooterLink[] = [{ label: "X", external: true }, { label: "GitHub", external: true }, { label: "LinkedIn", external: true }];
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
function FooterLink({ link, onNavigate, className, linkAs }: { link: SiteFooterLink; onNavigate?: (link: SiteFooterLink) => void; className?: string; linkAs?: ElementType }) {
const content = link.icon
? <><span className={styles.iconGlyph} aria-hidden="true">{link.icon}</span><span className={styles.srOnly}>{link.label}</span></>
: <>{link.label}{link.external && <HugeiconsIcon icon={ArrowUpRight01Icon} className={styles.external} size={13} strokeWidth={2} aria-hidden="true" />}</>;
const linkClass = className ?? (link.icon ? styles.iconLink : styles.link);
const onClick = () => onNavigate?.(link);
const Anchor: ElementType = linkAs && !link.external && !link.document ? linkAs : "a";
if (link.href) return <Anchor className={linkClass} href={link.href} hrefLang={link.hrefLang} onClick={onClick} {...(link.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>{content}</Anchor>;
return <button type="button" className={linkClass} onClick={onClick}>{content}</button>;
}
/** Email signup that validates in place, keeps its width while the button morphs, and confirms without a toast. */
function Newsletter({ title, description, placeholder = "you@example.com", onSubscribe }: SiteFooterNewsletter) {
const id = useId();
const reduced = !!useReducedMotion();
const [email, setEmail] = useState("");
const [state, setState] = useState<"idle" | "loading" | "done">("idle");
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const validate = (value: string) => !value.trim() ? "이메일 주소를 입력해 주세요" : EMAIL.test(value.trim()) ? null : "이메일 주소를 다시 확인해 주세요";
async function submit(event: FormEvent) {
event.preventDefault();
if (state !== "idle") return;
setTouched(true);
const problem = validate(email);
setError(problem);
if (problem) return;
setState("loading");
try {
await (onSubscribe ? onSubscribe(email.trim()) : new Promise(resolve => setTimeout(resolve, 900)));
setState("done");
} catch {
setState("idle");
setError("구독하지 못했어요. 잠시 후 다시 해 볼까요?");
}
}
const message = state === "done" ? `${email.trim()}에서 메일을 확인해 주세요` : error ?? description;
const tone = state === "done" ? "success" : error ? "error" : "hint";
return <form className={styles.newsletter} onSubmit={submit} noValidate aria-labelledby={title ? `${id}-title` : undefined}>
{title && <h2 id={`${id}-title`} className={styles.newsletterTitle}>{title}</h2>}
<div className={styles.newsletterRow} data-invalid={error ? "" : undefined} data-done={state === "done" ? "" : undefined}>
<label className={styles.srOnly} htmlFor={`${id}-email`}>이메일 주소</label>
<input
id={`${id}-email`}
className={styles.newsletterInput}
type="email"
inputMode="email"
autoComplete="email"
placeholder={placeholder}
value={email}
readOnly={state !== "idle"}
aria-invalid={error ? true : undefined}
aria-describedby={`${id}-message`}
onChange={event => { setEmail(event.target.value); if (touched) setError(validate(event.target.value)); }}
onBlur={() => { if (email) { setTouched(true); setError(validate(email)); } }}
/>
<Button type="submit" size="md" variant="primary" loading={state === "loading"} className={styles.newsletterButton} aria-disabled={state === "done" || undefined}>
{state === "done" ? <><UiIcon iconRole="check" size={15} strokeWidth={2.25} aria-hidden="true" />구독했어요</> : "구독하기"}
</Button>
</div>
<div className={styles.messageSlot} id={`${id}-message`} aria-live="polite">
<AnimatePresence initial={false} mode="popLayout">
{message && <motion.p key={message} className={styles.message} data-tone={tone} role={tone === "error" ? "alert" : undefined}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 4, filter: `blur(${motionTokens.blur.subtle}px)` }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant } }}
transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: enter }}>{message}</motion.p>}
</AnimatePresence>
</div>
</form>;
}
/**
* A website footer in three layouts: link columns with a newsletter signup, a single quiet row, and a closing
* variant where the brand mark rises in, oversized and cropped by the bottom edge, fading into the page.
*/
export const SiteFooter = forwardRef<HTMLElement, SiteFooterProps>(function SiteFooter({
variant = "columns",
brand = { name: BRAND_NAME },
tagline = "의도를 담아 움직이는 인터페이스 컴포넌트예요.",
columns = siteFooterExampleColumns,
links,
legal = exampleLegal,
socials = exampleSocials,
newsletter = { title: "매달 릴리스 노트를 받아 보세요", description: "한 달에 한 번만 보내 드려요. 언제든 구독을 취소할 수 있어요." },
status = { label: "모든 시스템이 정상이에요", tone: "success" },
year = new Date().getFullYear(),
onNavigate,
linkAs,
labels,
copyright,
illustration = false,
children,
className,
...rest
}, ref) {
const reduced = !!useReducedMotion();
const navLabel = labels?.nav ?? "푸터";
const brandNode = <FooterLink link={{ label: brand.name, href: brand.href }} onNavigate={onNavigate} className={styles.brand} linkAs={linkAs} />;
const BrandAnchor: ElementType = linkAs && brand.href ? linkAs : "a";
const brandWithMark = brand.mark
? <span className={styles.brandRow}>{brand.mark}{brandNode}</span>
: <BrandAnchor className={styles.brandLogo} href={brand.href} aria-label={brand.name || BRAND_NAME} onClick={() => onNavigate?.({ label: brand.name, href: brand.href })}><Devs7udioLogo height={26} /></BrandAnchor>;
const statusNode = status && (status.href
? <a className={styles.status} href={status.href} data-tone={status.tone ?? "success"} onClick={() => onNavigate?.({ label: status.label, href: status.href })}><span className={styles.statusDot} aria-hidden="true" />{status.label}</a>
: <button type="button" className={styles.status} data-tone={status.tone ?? "success"} onClick={() => onNavigate?.({ label: status.label })}><span className={styles.statusDot} aria-hidden="true" />{status.label}</button>);
const socialNode = socials.length > 0 && <ul className={styles.inline} aria-label={labels?.socials ?? "소셜 링크"}>{socials.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} linkAs={linkAs} /></li>)}</ul>;
const legalRow = <div className={styles.bottom}>
<div className={styles.bottomStart}>
<span className={styles.copyright}>{copyright ?? `© ${year} ${brand.name}`}</span>
{legal.length > 0 && <nav aria-label={labels?.legal ?? "약관 및 정책"}><ul className={styles.inline}>{legal.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} linkAs={linkAs} /></li>)}</ul></nav>}
</div>
<div className={styles.bottomEnd}>{statusNode}{socialNode}</div>
</div>;
const columnNav = <nav className={styles.columns} aria-label={navLabel}>
{columns.map(column => <div key={column.title} className={styles.column}>
<h2>{column.title}</h2>
<ul>{column.links.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} linkAs={linkAs} /></li>)}</ul>
</div>)}
</nav>;
const band = illustration && variant !== "logo" ? <Illustration name="footer-band" animated className={styles.band} /> : null;
if (variant === "minimal") {
const rowLinks = links ?? columns.map(column => column.links[0]).filter((link): link is SiteFooterLink => Boolean(link)).concat(legal.slice(0, 2));
return <footer ref={ref} {...rest} className={[styles.footer, styles.minimal, band ? styles.withBand : "", className].filter(Boolean).join(" ")}>
<div className={styles.minimalRow}>
{brandWithMark}
<nav aria-label={navLabel}><ul className={styles.inline}>{rowLinks.map(link => <li key={link.label}><FooterLink link={link} onNavigate={onNavigate} linkAs={linkAs} /></li>)}</ul></nav>
</div>
<div className={styles.minimalRow}>
<span className={styles.copyright}>{copyright ?? `© ${year} ${brand.name}. ${tagline}`}</span>
<div className={styles.bottomEnd}>{statusNode}{socialNode}</div>
</div>
{children}
{band}
</footer>;
}
return <footer ref={ref} {...rest} className={[styles.footer, variant === "logo" ? styles.logo : "", band ? styles.withBand : "", className].filter(Boolean).join(" ")}>
<div className={styles.top}>
<div className={styles.intro}>
{brandWithMark}
{tagline && <p className={styles.tagline}>{tagline}</p>}
{newsletter && variant === "columns" && <Newsletter {...newsletter} />}
</div>
{columnNav}
</div>
{legalRow}
{children}
{band}
{variant === "logo" && <motion.div className={styles.markStage} aria-hidden="true"
initial={reduced ? false : { opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: .3 }}
transition={{ duration: reduced ? 0 : .9, ease: enter }}>
<Devs7udioMark className={styles.bigMark} />
</motion.div>}
</footer>;
});
SiteFooter.displayName = "SiteFooter";
const variantOptions = [{ value: "columns", label: "열 구성" }, { value: "minimal", label: "미니멀" }, { value: "logo", label: "로고" }];
/** Preview: the footer with a switch between its three layouts and a note of the last link pressed. */
export function SiteFooterBlock({ variant: initial = "columns" }: { variant?: SiteFooterVariant }) {
const [variant, setVariant] = useState<SiteFooterVariant>(initial);
const [last, setLast] = useState<string | null>(null);
const reduced = !!useReducedMotion();
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | null>(null);
// The frame springs between layout heights instead of snapping; the new layout fades in where it sits.
useLayoutEffect(() => {
const node = contentRef.current;
if (!node) return;
setHeight(node.offsetHeight);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setHeight(node.offsetHeight));
observer.observe(node);
return () => observer.disconnect();
}, [variant]);
return <div className={styles.preview}>
<SegmentedControl label="푸터 레이아웃" options={variantOptions} value={variant} onValueChange={value => setVariant(value as SiteFooterVariant)} />
<div className={styles.frame}>
<motion.div className={styles.frameBody} initial={false} animate={{ height: height ?? "auto" }} transition={reduced ? { duration: 0 } : motionTokens.spring.smooth}>
<motion.div ref={contentRef} key={variant} initial={reduced ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: motionTokens.duration.standard, ease: enter }}>
<SiteFooter variant={variant} illustration onNavigate={link => setLast(link.label)} />
</motion.div>
</motion.div>
</div>
<p className={styles.srOnly} aria-live="polite">{last ? `${last}을(를) 열었어요` : ""}</p>
</div>;
}
export default SiteFooterBlock;
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.
- IllustrationComponent
Flat shape illustrations for heroes, section headers and card covers. A theme without its own art draws the base art in its colours.