버튼·입력·선택·카드·토스트·모달을 모은 컴포넌트 시스템 문서
콘텐츠 블록버튼 4종 x 3크기 x 기본/비활성, 입력·텍스트영역·선택 상자의 4가지 상태, 체크박스·라디오, 카드 3종, 토스트 4종, 탭, 모달 3크기를 점선 패널에 모아 보여 주는 컴포넌트 문서 섹션입니다. 체크박스·라디오·탭·토스트 닫기가 실제로 동작합니다.
Claude Code · 모름 · React (TSX)
결과 코드펼치기
import { useState } from "react";
const FONT_URL = "https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap";
type Size = "SM" | "MD" | "LG";
type BtnType = "Primary" | "Secondary" | "Ghost" | "Danger";
type FieldState = "Default" | "Focused" | "Error" | "Disabled";
const SIZE_CLASS: Record<Size, string> = {
SM: "h-8 px-3 rounded-md text-[12px]",
MD: "h-10 px-4 rounded-lg text-[14px]",
LG: "h-12 px-5 rounded-lg text-[15px]",
};
const TYPE_CLASS: Record<BtnType, string> = {
Primary: "bg-[#5b8def] text-white hover:bg-[#3b82f6] disabled:bg-[#e5e7eb] disabled:text-[#d1d5db]",
Secondary: "border-[1.5px] border-[#5b8def] text-[#5b8def] hover:bg-[#eff6ff] disabled:border-[#e5e7eb] disabled:text-[#d1d5db] disabled:bg-transparent",
Ghost: "text-[#111827] hover:bg-[#f3f4f6] disabled:text-[#d1d5db] disabled:bg-transparent",
Danger: "bg-[#ff4d4f] text-white hover:bg-[#e53e40] disabled:bg-[#e5e7eb] disabled:text-[#d1d5db]",
};
const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5b8def]";
function Button({ type, size, disabled }: { type: BtnType; size: Size; disabled?: boolean }) {
return (
<button
type="button"
disabled={disabled}
className={`flex w-[120px] items-center justify-center font-semibold leading-normal transition-colors duration-150 disabled:cursor-not-allowed ${SIZE_CLASS[size]} ${TYPE_CLASS[type]} ${focusRing}`}
>
Label
</button>
);
}
function fieldBorder(state: FieldState) {
if (state === "Disabled") return "bg-[#f3f4f6] border border-[#e5e7eb] text-[#d1d5db] placeholder:text-[#d1d5db] cursor-not-allowed";
if (state === "Error") return "bg-white border-[1.5px] border-[#ff4d4f] text-[#111827] placeholder:text-[#9ca3af]";
if (state === "Focused") return "bg-white border-2 border-[#5b8def] ring-4 ring-[#5b8def]/20 text-[#111827] placeholder:text-[#9ca3af]";
return "bg-white border border-[#e5e7eb] text-[#111827] placeholder:text-[#9ca3af] focus:border-2 focus:border-[#5b8def] focus:ring-4 focus:ring-[#5b8def]/20";
}
function ErrorDot() {
return <span className="pointer-events-none absolute right-3 size-2 rounded-full bg-[#ff4d4f]" aria-hidden="true" />;
}
function Field({ state, multiline }: { state: FieldState; multiline?: boolean }) {
const common = `w-full rounded-lg px-2.5 text-[14px] leading-normal outline-none transition-[border-color,box-shadow] ${fieldBorder(state)}`;
const placeholder = state === "Disabled" ? "Disabled" : "Placeholder";
const defaultValue = state === "Focused" ? "Value text" : undefined;
const label = `${multiline ? "Textarea" : "Input"} ${state}`;
return (
<div className="relative flex w-full items-center">
{multiline ? (
<textarea aria-label={label} rows={3} disabled={state === "Disabled"} placeholder={placeholder} defaultValue={defaultValue} aria-invalid={state === "Error" || undefined} className={`${common} h-[88px] resize-none py-2.5`} />
) : (
<input aria-label={label} disabled={state === "Disabled"} placeholder={placeholder} defaultValue={defaultValue} aria-invalid={state === "Error" || undefined} className={`${common} h-10`} />
)}
{state === "Error" && <ErrorDot />}
</div>
);
}
function Select({ state }: { state: FieldState }) {
const isOpen = state === "Focused";
return (
<div className="relative flex w-full items-center">
<select
aria-label={`Select ${state}`}
disabled={state === "Disabled"}
defaultValue={isOpen ? "selected" : ""}
className={`h-10 w-full cursor-pointer appearance-none rounded-lg px-2.5 pr-8 text-[14px] leading-normal outline-none ${fieldBorder(state)} ${isOpen ? "" : "text-[#9ca3af]"}`}
>
<option value="" disabled>
{state === "Disabled" ? "Disabled" : "Select option"}
</option>
<option value="selected">Selected value</option>
<option value="other">Other value</option>
</select>
<svg className="pointer-events-none absolute right-3" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={state === "Disabled" ? "#d1d5db" : isOpen ? "#111827" : "#9ca3af"} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</div>
);
}
function Panel({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return <div className={`rounded-xl border border-dashed border-[#e5e7eb] bg-[#f9fafb] p-4 sm:p-5 ${className}`}>{children}</div>;
}
function SectionTitle({ title, desc }: { title: string; desc: string }) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<span className="h-6 w-1 shrink-0 rounded-[2px] bg-[#5b8def]" aria-hidden="true" />
<h3 className="text-[18px] font-bold leading-normal text-[#111827] sm:text-[20px]">{title}</h3>
</div>
<p className="text-[14px] leading-normal text-[#6b7280]">{desc}</p>
</div>
);
}
type CheckState = "unchecked" | "checked" | "indeterminate";
function CheckboxRow({ initial, disabled }: { initial: CheckState; disabled?: boolean }) {
const [state, setState] = useState<CheckState>(initial);
const on = state !== "unchecked";
return (
<label className={`flex items-center gap-2 ${disabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
<input
type="checkbox"
className="peer sr-only"
checked={state === "checked"}
disabled={disabled}
ref={(el) => {
if (el) el.indeterminate = state === "indeterminate";
}}
onChange={() => setState(state === "checked" ? "unchecked" : "checked")}
/>
<span
className={`flex size-4 items-center justify-center rounded-[4px] transition-colors peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[#5b8def] ${
disabled ? "bg-[#e5e7eb]" : on ? "bg-[#5b8def]" : "border-[1.5px] border-[#e5e7eb] bg-white"
}`}
aria-hidden="true"
>
{state === "checked" && !disabled && (
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="4" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
)}
{state === "indeterminate" && !disabled && <span className="h-[2px] w-2 rounded bg-white" />}
</span>
<span className={`text-[14px] leading-normal ${disabled ? "text-[#d1d5db]" : "text-[#111827]"}`}>Checkbox label</span>
</label>
);
}
const TOASTS = [
{ type: "Success", text: "Action completed successfully.", color: "#27ae60", bg: "#f0fdf4" },
{ type: "Error", text: "Something went wrong.", color: "#ff4d4f", bg: "#fef2f2" },
{ type: "Warning", text: "Please review your input.", color: "#f2c94c", bg: "#fefce8" },
{ type: "Info", text: "New update is available.", color: "#5b8def", bg: "#eff6ff" },
];
const MODAL_SIZES: { size: Size; width: string; body: string }[] = [
{ size: "SM", width: "max-w-[400px]", body: "h-20" },
{ size: "MD", width: "max-w-[520px]", body: "h-[100px]" },
{ size: "LG", width: "max-w-[640px]", body: "h-[120px]" },
];
export default function ComponentSystemDoc() {
const [radio, setRadio] = useState("b");
const [tab, setTab] = useState(0);
const [toasts, setToasts] = useState(TOASTS.map((t) => t.type));
const BUTTON_TYPES: BtnType[] = ["Primary", "Secondary", "Ghost", "Danger"];
const SIZES: Size[] = ["SM", "MD", "LG"];
const STATES: FieldState[] = ["Default", "Focused", "Error", "Disabled"];
return (
<section className="w-full bg-white px-4 py-12 font-['Geist',sans-serif] sm:px-10 sm:py-20 lg:px-20">
<link rel="stylesheet" href={FONT_URL} precedence="default" />
<div className="mx-auto flex w-full max-w-[1520px] flex-col gap-14">
<h2 className="border-b border-[#e5e7eb] pb-5 text-[24px] font-bold leading-normal text-[#111827] sm:text-[28px]">07 · COMPONENT SYSTEM</h2>
<div className="flex flex-col gap-6">
<SectionTitle title="Form Controls" desc="버튼·입력·선택 컨트롤" />
<div className="flex flex-wrap items-start gap-4">
<Panel>
<div className="flex flex-col gap-6">
{BUTTON_TYPES.map((t) => (
<div key={t} className="flex flex-col gap-2" role="group" aria-label={`${t} buttons`}>
{SIZES.map((s) => (
<div key={s} className="flex items-center gap-2">
<Button type={t} size={s} />
<Button type={t} size={s} disabled />
</div>
))}
</div>
))}
</div>
</Panel>
<Panel className="w-full sm:w-[280px]">
<div className="flex flex-col gap-2">
{STATES.map((s) => (
<Field key={s} state={s} />
))}
</div>
</Panel>
<Panel className="w-full sm:w-[280px]">
<div className="flex flex-col gap-2">
{STATES.map((s) => (
<Field key={s} state={s} multiline />
))}
</div>
</Panel>
<Panel className="w-full sm:w-[280px]">
<div className="flex flex-col gap-2">
{STATES.map((s) => (
<Select key={s} state={s} />
))}
</div>
</Panel>
</div>
</div>
<div className="flex flex-col gap-6">
<SectionTitle title="Selection Controls" desc="체크박스·라디오 선택 요소" />
<div className="flex flex-wrap items-start gap-6">
<Panel>
<fieldset className="flex flex-col gap-3">
<legend className="sr-only">Checkbox states</legend>
<CheckboxRow initial="unchecked" />
<CheckboxRow initial="checked" />
<CheckboxRow initial="indeterminate" />
<CheckboxRow initial="unchecked" disabled />
</fieldset>
</Panel>
<Panel>
<fieldset className="flex flex-col gap-3">
<legend className="sr-only">Radio states</legend>
{[
{ id: "a", disabled: false },
{ id: "b", disabled: false },
{ id: "c", disabled: true },
].map((r) => {
const on = radio === r.id && !r.disabled;
return (
<label key={r.id} className={`flex items-center gap-2 ${r.disabled ? "cursor-not-allowed" : "cursor-pointer"}`}>
<input type="radio" name="cs-radio" value={r.id} checked={radio === r.id} disabled={r.disabled} onChange={() => setRadio(r.id)} className="peer sr-only" />
<span
className={`flex size-4 items-center justify-center rounded-full peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[#5b8def] ${
r.disabled ? "bg-[#e5e7eb]" : on ? "border-[5px] border-[#5b8def] bg-white" : "border-[1.5px] border-[#e5e7eb] bg-white"
}`}
aria-hidden="true"
/>
<span className={`text-[14px] leading-normal ${r.disabled ? "text-[#d1d5db]" : "text-[#111827]"}`}>Radio label</span>
</label>
);
})}
</fieldset>
</Panel>
</div>
</div>
<div className="flex flex-col gap-6">
<SectionTitle title="Data Display" desc="카드·토스트 정보 표시 컴포넌트" />
<div className="flex flex-wrap items-start gap-4">
<Panel className="w-full xl:w-auto">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[
{ name: "Default", cls: "border border-[#e5e7eb]" },
{ name: "Outlined", cls: "border border-[#d1d5db]" },
{ name: "Elevated", cls: "shadow-[0px_4px_12px_0px_rgba(17,24,39,0.1)]" },
].map((c) => (
<article key={c.name} className={`h-[180px] w-full overflow-hidden rounded-xl bg-white sm:w-[240px] lg:w-[280px] ${c.cls}`}>
<div className="h-24 w-full bg-[#f3f4f6]" />
<div className="flex flex-col gap-1 px-4 pt-3">
<h4 className="text-[15px] font-semibold leading-normal text-[#111827]">Card title</h4>
<p className="text-[13px] leading-normal text-[#6b7280]">Short description text</p>
</div>
</article>
))}
</div>
</Panel>
<Panel className="w-full sm:w-auto">
<ul className="flex flex-col gap-4">
{TOASTS.filter((t) => toasts.includes(t.type)).map((t) => (
<li
key={t.type}
role={t.type === "Error" ? "alert" : "status"}
className="relative flex h-14 w-full items-center gap-2.5 overflow-hidden rounded-lg border pl-5 pr-3 sm:w-[360px]"
style={{ background: t.bg, borderColor: t.color }}
>
<span className="absolute inset-y-0 left-0 w-1" style={{ background: t.color }} aria-hidden="true" />
<span className="size-2 shrink-0 rounded-full" style={{ background: t.color }} aria-hidden="true" />
<span className="flex-1 text-[13px] leading-normal text-[#111827]">{t.text}</span>
<button
type="button"
onClick={() => setToasts((s) => s.filter((x) => x !== t.type))}
aria-label={`${t.type} 알림 닫기`}
className={`rounded px-1.5 text-[16px] leading-none text-[#9ca3af] hover:text-[#111827] ${focusRing}`}
>
×
</button>
</li>
))}
{toasts.length < TOASTS.length && (
<li>
<button type="button" onClick={() => setToasts(TOASTS.map((t) => t.type))} className={`text-[12px] font-medium text-[#5b8def] hover:underline ${focusRing}`}>
알림 다시 보기
</button>
</li>
)}
</ul>
</Panel>
</div>
<Panel className="self-start">
<div role="tablist" aria-label="Tabs example" className="flex">
{["Tab", "Tab", "Tab"].map((label, i) => {
const disabled = i === 2;
const active = tab === i;
return (
<button
key={i}
type="button"
role="tab"
aria-selected={active}
disabled={disabled}
onClick={() => setTab(i)}
className={`flex flex-col items-center gap-2 px-4 pt-2.5 text-[14px] leading-normal transition-colors ${focusRing} ${
disabled ? "cursor-not-allowed text-[#d1d5db]" : active ? "font-semibold text-[#5b8def]" : "text-[#6b7280] hover:text-[#111827]"
}`}
>
{label}
<span className={`h-[2px] w-12 rounded-[2px] ${active ? "bg-[#5b8def]" : "bg-transparent"}`} />
</button>
);
})}
</div>
</Panel>
</div>
<div className="flex flex-col gap-6">
<SectionTitle title="Overlay" desc="모달 오버레이 컴포넌트" />
<Panel>
<div className="flex flex-wrap items-start gap-4">
{MODAL_SIZES.map((m) => (
<div
key={m.size}
role="dialog"
aria-label={`Modal ${m.size}`}
className={`w-full ${m.width} overflow-hidden rounded-2xl border border-[#e5e7eb] bg-white shadow-[0px_8px_24px_0px_rgba(17,24,39,0.16)]`}
>
<div className="flex h-16 items-center justify-between border-b border-[#e5e7eb] px-6">
<h4 className="text-[16px] font-semibold leading-normal text-[#111827]">Modal title</h4>
<button type="button" aria-label="닫기" className={`flex size-7 items-center justify-center rounded-md bg-[#f3f4f6] text-[16px] text-[#9ca3af] hover:text-[#111827] ${focusRing}`}>
×
</button>
</div>
<div className={`px-6 pt-5 ${m.body}`}>
<p className="text-[14px] leading-normal text-[#6b7280]">Modal body content goes here.</p>
</div>
<div className="flex h-[63px] items-center justify-end gap-2 border-t border-[#e5e7eb] bg-[#f9fafb] px-6">
<button type="button" className={`h-9 w-20 rounded-lg border border-[#e5e7eb] bg-white text-[13px] font-medium text-[#6b7280] hover:bg-[#f3f4f6] ${focusRing}`}>
Cancel
</button>
<button type="button" className={`h-9 w-20 rounded-lg bg-[#5b8def] text-[13px] font-medium text-white hover:bg-[#3b82f6] ${focusRing}`}>
Confirm
</button>
</div>
</div>
))}
</div>
</Panel>
</div>
<p className="whitespace-pre border-t border-[#e5e7eb] pt-4 text-[12px] leading-normal text-[#a5a9b2]">{"Design System · Component System · v1.0"}</p>
</div>
</section>
);
}
적용 사이트 0
이 블록이 실제로 쓰인 사이트입니다.
아직 등록된 사이트가 없습니다. 이 블록을 쓴 사이트가 있다면 알려 주세요.
재현 후기
v1 · 0개아직 이 버전의 프롬프트로 직접 만들어 본 사람이 없습니다. 첫 번째로 재현해 보세요.