Claude Code로 접근성 있는 React 토스트 알림 구현하기
큐, 자동 닫힘, 일시정지, aria-live, reduced motion, 모바일 safe area까지 갖춘 React toast 구현 가이드.
토스트 알림은 “저장되었습니다”, “내보내기를 시작했습니다”, “결제에 실패했습니다”처럼 짧은 상태를 알려주는 UI입니다. 작아 보여도 접근성, 모바일 레이아웃, 전환 CTA, 오류 복구에 직접 영향을 줍니다. 너무 빨리 사라지거나, 모든 메시지를 alert로 읽게 하거나, 모바일에서 하단 버튼을 덮으면 실제 제품 품질이 떨어집니다.
Claude Code는 토스트 컴포넌트를 빠르게 만들 수 있습니다. 하지만 운영 품질을 내려면 큐, 자동 닫힘, hover/focus 중 일시정지, role="status"와 role="alert"의 구분, prefers-reduced-motion, safe area, 리뷰 프롬프트까지 요구해야 합니다. 관련 내용은 Claude Code 접근성, 애니메이션 구현, 반응형 디자인, React 개발과 함께 보면 좋습니다.
설계 기준
토스트는 모달이 아닙니다. 포커스를 가두거나 작업을 멈추게 해서는 안 됩니다. 저장 완료, 복사 성공, 백그라운드 작업 시작처럼 짧은 피드백에 적합합니다. 사용자가 필드를 고치거나 결제를 다시 시도하거나 위험한 작업을 확인해야 한다면 페이지 안에 지속되는 메시지를 남겨야 합니다.
이 구현의 기준은 다음과 같습니다.
- 동시에 최대 3개만 표시
- 성공, 정보, 경고는
role="status" - 즉시 주의가 필요한 오류만
role="alert" - 자동으로 닫히지만 hover와 focus 중에는 일시정지
- 모든 알림에 닫기 버튼 제공
prefers-reduced-motion에서 애니메이션 제거- CSS safe area로 모바일 노치와 홈 인디케이터 회피
공식 문서는 MDN status role, MDN alert role, W3C WCAG Status Messages, W3C WCAG Pause, Stop, Hide, MDN setTimeout, MDN prefers-reduced-motion, Claude Code docs를 기준으로 확인하세요.
복사해서 쓸 수 있는 React 코드
ToastProvider.tsx를 만듭니다. React 외 의존성은 없습니다. Next.js App Router라면 맨 위에 "use client";를 추가하세요.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
type ToastTone = "success" | "info" | "warning" | "error";
type ToastInput = {
title: string;
description?: string;
tone?: ToastTone;
durationMs?: number;
};
type ToastItem = Required<Omit<ToastInput, "durationMs">> & {
id: string;
durationMs: number;
createdAt: number;
};
type ToastContextValue = {
showToast: (input: ToastInput) => string;
dismissToast: (id: string) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
const MAX_VISIBLE_TOASTS = 3;
const DEFAULT_DURATION = 5000;
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const dismissToast = useCallback((id: string) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback((input: ToastInput) => {
const id = crypto.randomUUID();
const nextToast: ToastItem = {
id,
title: input.title,
description: input.description ?? "",
tone: input.tone ?? "info",
durationMs: input.durationMs ?? DEFAULT_DURATION,
createdAt: Date.now(),
};
setToasts((current) => [...current, nextToast].slice(-MAX_VISIBLE_TOASTS));
return id;
}, []);
const value = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
return (
<ToastContext.Provider value={value}>
{children}
<ToastViewport toasts={toasts} onDismiss={dismissToast} />
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) throw new Error("useToast must be used inside ToastProvider");
return context;
}
function ToastViewport({ toasts, onDismiss }: { toasts: ToastItem[]; onDismiss: (id: string) => void }) {
return (
<div className="toast-viewport" aria-label="알림">
{toasts.map((toast) => (
<ToastCard key={toast.id} toast={toast} onDismiss={onDismiss} />
))}
</div>
);
}
function ToastCard({ toast, onDismiss }: { toast: ToastItem; onDismiss: (id: string) => void }) {
const [paused, setPaused] = useState(false);
const remainingMs = useRef(toast.durationMs);
const startedAt = useRef<number | null>(null);
const timeoutId = useRef<number | null>(null);
useEffect(() => {
if (toast.durationMs <= 0 || paused) return;
startedAt.current = Date.now();
timeoutId.current = window.setTimeout(() => onDismiss(toast.id), remainingMs.current);
return () => {
if (timeoutId.current !== null) window.clearTimeout(timeoutId.current);
if (startedAt.current !== null) remainingMs.current -= Date.now() - startedAt.current;
};
}, [onDismiss, paused, toast.durationMs, toast.id]);
const role = toast.tone === "error" ? "alert" : "status";
return (
<section
className={`toast-card toast-card--${toast.tone}`}
role={role}
aria-atomic="true"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
>
<div className="toast-card__content">
<strong className="toast-card__title">{toast.title}</strong>
{toast.description ? <p>{toast.description}</p> : null}
</div>
<button
type="button"
className="toast-card__close"
aria-label={`${toast.title} 닫기`}
onClick={() => onDismiss(toast.id)}
>
×
</button>
</section>
);
}
toast.css를 추가합니다.
.toast-viewport {
position: fixed;
top: max(16px, env(safe-area-inset-top));
right: max(16px, env(safe-area-inset-right));
z-index: 1000;
display: grid;
gap: 10px;
width: min(380px, calc(100vw - 32px));
pointer-events: none;
}
.toast-card {
pointer-events: auto;
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 12px;
padding: 14px 14px 14px 16px;
border: 1px solid #d8dee8;
border-left-width: 5px;
border-radius: 8px;
background: #fff;
color: #172033;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
animation: toast-slide-in 180ms ease-out;
}
.toast-card--success { border-left-color: #15803d; }
.toast-card--info { border-left-color: #2563eb; }
.toast-card--warning { border-left-color: #b45309; }
.toast-card--error { border-left-color: #b91c1c; }
.toast-card__title { display: block; font-size: 0.95rem; line-height: 1.35; }
.toast-card p { margin: 4px 0 0; color: #46536a; font-size: 0.875rem; line-height: 1.5; }
.toast-card__close {
min-width: 32px;
min-height: 32px;
border: 0;
border-radius: 6px;
background: transparent;
color: #526071;
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
}
.toast-card__close:hover,
.toast-card__close:focus-visible {
background: #eef2f7;
outline: 2px solid transparent;
}
@keyframes toast-slide-in {
from { opacity: 0; transform: translateY(-8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 640px) {
.toast-viewport { left: 16px; right: 16px; width: auto; }
}
@media (prefers-reduced-motion: reduce) {
.toast-card { animation: none; }
}
사용 예시는 다음과 같습니다.
import { ToastProvider, useToast } from "./ToastProvider";
import "./toast.css";
function SaveProfileButton() {
const { showToast } = useToast();
async function handleSave() {
try {
await new Promise((resolve) => window.setTimeout(resolve, 600));
showToast({
tone: "success",
title: "프로필을 저장했습니다",
description: "다음에 이 화면을 열 때도 변경 사항이 반영됩니다.",
});
} catch {
showToast({
tone: "error",
title: "저장에 실패했습니다",
description: "네트워크 상태를 확인한 뒤 다시 시도하세요.",
durationMs: 8000,
});
}
}
return <button onClick={handleSave}>저장</button>;
}
export default function App() {
return (
<ToastProvider>
<main>
<h1>설정</h1>
<SaveProfileButton />
</main>
</ToastProvider>
);
}
실제 사용 사례
첫 번째는 설정 저장입니다. 저장 후 페이지가 그대로 남아 있다면 성공 토스트가 불안을 줄입니다. 하지만 입력 오류는 필드 근처에 남겨야 하며, 토스트는 “3개 항목을 확인하세요” 정도의 요약으로만 사용합니다.
두 번째는 백그라운드 작업입니다. CSV 내보내기, AI 요약, 이미지 처리, 이메일 발송은 시작, 완료, 실패 상태가 모두 필요합니다. Claude Code에는 성공 경로뿐 아니라 실패, 재시도, 취소 후 메시지도 요구하세요.
세 번째는 수익화 흐름입니다. 무료 PDF 발송, 유료 다운로드 준비, 상담 신청 접수는 토스트로 자연스럽게 알릴 수 있습니다. 단, 가격 CTA, Gumroad 링크, 뉴스레터 폼, 모바일 하단 고정 버튼을 가리면 안 됩니다. 전환 측정은 Claude Code analytics implementation와 함께 설계하세요.
흔한 실패
모든 메시지를 alert로 만들지 마세요. 성공, 복사 완료, 저장 완료는 status가 적절합니다. alert는 연결 끊김, 세션 만료, 저장 불가처럼 즉시 알아야 하는 오류에만 사용합니다.
너무 빨리 사라지게 하지 마세요. 번역 후 문장이 길어지고, 모바일에서는 읽는 속도도 느려집니다. 기본 5초, 오류 8초 정도가 현실적입니다. hover/focus 중 일시정지는 키보드 사용자가 닫기 버튼까지 이동할 시간을 확보합니다.
중요 정보를 토스트에만 두지 마세요. 결제 실패, 권한 부족, 폼 검증 오류는 화면 안에 지속적으로 표시해야 합니다. 토스트는 사라지는 UI입니다.
반복 애니메이션과 깜빡임도 피하세요. 입장 애니메이션은 짧게, reduced motion 설정에서는 끄는 것이 안전합니다.
Claude Code 프롬프트
접근성 있는 React + TypeScript 토스트 알림을 구현하세요.
수정 파일은 ToastProvider.tsx 와 toast.css 로 제한합니다.
요구사항:
- success/info/warning/error
- 최대 3개 표시
- 자동 닫힘, 닫기 버튼, hover/focus 중 일시정지
- 일반 메시지는 role="status"
- 긴급 오류만 role="alert"
- aria-atomic="true"
- prefers-reduced-motion 지원
- 모바일 safe-area 지원
- 중요한 폼 오류를 토스트에만 두지 않기
검증:
- npm run typecheck
- npm run lint
- 성공, 실패, 3개 초과, hover 일시정지, focus 일시정지, 키보드 닫기 확인
리뷰 프롬프트:
이 토스트 구현을 비판적으로 리뷰하세요.
aria-live/status/alert 선택, 자동 닫힘 시간, hover/focus 일시정지,
reduced motion, 모바일 safe area, 키보드 조작, 중요한 정보가 사라지는지 확인하세요.
문제는 심각도순으로 파일, 라인, 수정안과 함께 제시하세요.
팀에서는 이 기준을 CLAUDE.md best practices와 review workflow checklist에 넣어 반복 사용하세요.
직접 검증한 결과
작은 React 검증 프로젝트에서 ToastProvider.tsx와 toast.css를 분리해 붙여 넣고, 성공, 오류, 3개 초과, hover 일시정지, focus 일시정지, 닫기 버튼, reduced motion을 확인했습니다. 남은 시간이 useRef에 저장되므로 일시정지 후 전체 5초가 다시 시작되지 않고 남은 시간으로 이어집니다. 운영 환경에서는 Playwright 상호작용 테스트, axe 검사, 스크린 리더 수동 확인을 추가하는 것이 좋습니다.
마무리
토스트 알림은 작은 UI지만 접근성, 신뢰, 모바일 전환, 지원 비용에 영향을 줍니다. Claude Code에 요청할 때는 시각 컴포넌트만이 아니라 동작, 제한, 리뷰 기준까지 적으세요.
개인은 무료 Claude Code cheatsheet로 기본 습관을 잡고, 프롬프트와 설정 자료가 필요하면 ClaudeCodeLab 제품을 확인하세요. 팀에서 UI 리뷰, 접근성 규칙, 수익화 흐름까지 표준화하려면 Claude Code 교육 및 컨설팅에서 시작할 수 있습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code 권한 세이프티 래더: 통제력을 잃지 않고 allow 넓히기
read-only에서 제한 편집, 검증 명령, deploy 확인까지 권한을 단계적으로 넓히는 방법.
Claude Code Small PR Proof Pack: 작은 PR을 리뷰 가능한 상태로 만드는 증거 세트
Claude Code의 작은 PR에 diff, 검증, 공개 URL, CTA 경로, rollback을 붙이는 실무 체크리스트.
Claude Code 커밋 전 리뷰 게이트: diff, 테스트, 공개 URL, CTA 확인
Claude Code 작업을 커밋하기 전에 diff 범위, build, 공개 URL, Gumroad 링크, 상담 CTA, 테스트 누락과 무관한 파일을 확인하는 방법입니다.