Alert
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
저장 완료
변경사항이 정상적으로 저장되었습니다.
Alert / Confirm
Promise 기반 Alert / Confirm 모달을 사용하는 예시입니다. 각 버튼을 눌러 실제 동작을 확인할 수 있습니다.
기본 Alert
가장 단순한 정보용 Alert 예시
Source Code
Live Preview
여러 Variant Alert
success / warning / error 등의 다양한 Alert variant
Success
Live Preview
Warning
Live Preview
Error
Live Preview
Confirm (확인 / 취소)
사용자의 선택에 따라 분기 처리하는 Confirm 예시
Source Code
Live Preview
비동기 플로우 (Alert → Confirm → Alert)
Alert와 Confirm을 조합해 단계적인 비동기 플로우를 구성하는 예시
Source Code
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
AlertModaltypescript
/**
* Alert Modal 컴포넌트
*
* 정보를 표시하는 Alert 모달입니다.
* variant별 아이콘·색상·스타일로 시각적 구분을 줍니다.
*/
'use client';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/cn';
import { Button } from '@/components';
import type { AlertOptions } from './types';
interface AlertModalProps {
options: AlertOptions;
onClose: () => void;
}
type Variant = NonNullable<AlertOptions['variant']>;
const variantConfig: Record<
Variant,
{
accent: string;
iconBg: string;
iconColor: string;
titleColor: string;
buttonVariant: 'primary' | 'danger' | 'secondary';
}
> = {
default: {
accent: 'bg-slate-100 border-slate-200',
iconBg: 'bg-slate-100',
iconColor: 'text-slate-600',
titleColor: 'text-slate-900',
buttonVariant: 'primary',
},
info: {
accent: 'bg-blue-50 border-blue-200',
iconBg: 'bg-blue-100',
iconColor: 'text-blue-600',
titleColor: 'text-blue-900',
buttonVariant: 'primary',
},
success: {
accent: 'bg-emerald-50 border-emerald-200',
iconBg: 'bg-emerald-100',
iconColor: 'text-emerald-600',
titleColor: 'text-emerald-900',
buttonVariant: 'primary',
},
warning: {
accent: 'bg-amber-50 border-amber-200',
iconBg: 'bg-amber-100',
iconColor: 'text-amber-600',
titleColor: 'text-amber-900',
buttonVariant: 'primary',
},
error: {
accent: 'bg-red-50 border-red-200',
iconBg: 'bg-red-100',
iconColor: 'text-red-600',
titleColor: 'text-red-900',
buttonVariant: 'danger',
},
};
function VariantIcon({ variant, className }: { variant: Variant; className?: string }) {
const c = cn('size-6', className);
switch (variant) {
case 'success':
return (
<svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case 'warning':
return (
<svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
);
case 'error':
return (
<svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
case 'info':
case 'default':
default:
return (
<svg className={c} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
);
}
}
export function AlertModal({ options, onClose }: AlertModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const confirmButtonRef = useRef<HTMLButtonElement>(null);
const variant = options.variant ?? 'default';
const config = variantConfig[variant];
useEffect(() => {
confirmButtonRef.current?.focus();
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const handleEnter = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleEscape);
document.addEventListener('keydown', handleEnter);
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleEscape);
document.removeEventListener('keydown', handleEnter);
document.body.style.overflow = '';
};
}, [onClose]);
if (typeof window === 'undefined') return null;
return createPortal(
<div
className="fixed inset-0 z-120 flex items-center justify-center p-4 animate-in fade-in duration-200"
role="dialog"
aria-modal="true"
aria-labelledby={options.title ? 'alert-title' : undefined}
aria-describedby="alert-message"
>
<div
className="absolute inset-0 bg-black/40 backdrop-blur-md"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={modalRef}
className="relative z-10 w-full max-w-md overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 animate-in zoom-in-95 duration-200"
onClick={(e) => e.stopPropagation()}
>
{/* 상단 악센트 + 아이콘 */}
<div className={cn('border-b px-6 pt-6 pb-4', config.accent)}>
<div className="flex items-start gap-4">
<span className={cn('flex size-12 shrink-0 items-center justify-center rounded-xl', config.iconBg, config.iconColor)}>
<VariantIcon variant={variant} />
</span>
<div className="min-w-0 flex-1 pt-0.5">
{options.title && (
<h2 id="alert-title" className={cn('text-lg font-bold', config.titleColor)}>
{options.title}
</h2>
)}
<p id="alert-message" className={cn('mt-1 text-[15px] leading-relaxed text-slate-600', !options.title && 'mt-0')}>
{options.message}
</p>
</div>
</div>
</div>
{/* 액션 */}
<div className="flex p-4">
<Button
ref={confirmButtonRef}
onClick={onClose}
variant={config.buttonVariant}
className="w-full rounded-xl py-3 font-semibold shadow-sm"
>
{options.confirmText || '확인'}
</Button>
</div>
</div>
</div>,
document.body
);
}