Callout

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

Callout

페이지 흐름 안에 박아넣는 인라인 알림(공지) 박스입니다. 모달인 Alert와 달리 본문 레이아웃 안에서 정보를 강조할 때 사용합니다.

Variants

success / info / warning / error / neutral 5종

Source Code
<Callout variant="success" title="저장 완료">변경사항이 저장되었습니다.</Callout>
<Callout variant="warning" title="주의">되돌릴 수 없는 작업입니다.</Callout>
Live Preview

옵션 — 제목 없이 / 아이콘 숨김 / 닫기

title 없이 본문만, icon={false}로 아이콘 숨김, onClose로 닫기 버튼을 노출합니다.

Source Code
<Callout variant="info">본문만 있는 형태</Callout>
<Callout variant="neutral" icon={false}>아이콘 숨김</Callout>
<Callout variant="warning" title="공지" onClose={() => ...}>닫을 수 있어요</Callout>
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Callouttypescript
'use client';

/**
 * Callout 컴포넌트
 *
 * 페이지 흐름 안에 박아넣는 인라인 알림(공지) 박스입니다.
 * - 모달인 Alert(useAlert)와 달리, 본문 레이아웃 안에서 정보를 강조할 때 사용합니다.
 * - variant별 색/아이콘으로 success / info / warning / error / neutral을 구분합니다.
 * - title·아이콘·닫기 버튼은 모두 선택. 본문(children)만으로도 동작합니다.
 */

import type { ReactNode } from 'react';
import { cn } from '@/lib/cn';

export type CalloutVariant = 'info' | 'success' | 'warning' | 'error' | 'neutral';

export interface CalloutProps {
  /** 색·기본 아이콘을 결정하는 변형 */
  variant?: CalloutVariant;
  /** 굵은 제목 (선택) */
  title?: ReactNode;
  /** 커스텀 아이콘. false면 아이콘 영역 자체를 숨김 */
  icon?: ReactNode | false;
  /** 우측 닫기 버튼 핸들러 (선택) */
  onClose?: () => void;
  /** 본문 */
  children?: ReactNode;
  className?: string;
}

const variantStyles: Record<
  CalloutVariant,
  { box: string; icon: string; title: string }
> = {
  info: { box: 'border-blue-200 bg-blue-50', icon: 'text-blue-500', title: 'text-blue-900' },
  success: { box: 'border-emerald-200 bg-emerald-50', icon: 'text-emerald-500', title: 'text-emerald-900' },
  warning: { box: 'border-amber-200 bg-amber-50', icon: 'text-amber-500', title: 'text-amber-900' },
  error: { box: 'border-rose-200 bg-rose-50', icon: 'text-rose-500', title: 'text-rose-900' },
  neutral: { box: 'border-slate-200 bg-slate-50', icon: 'text-slate-500', title: 'text-slate-900' },
};

const ICON_PATHS: Record<CalloutVariant, string> = {
  info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
  neutral: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
  success: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
  warning:
    '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',
  error: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
};

function DefaultIcon({ variant, className }: { variant: CalloutVariant; className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
      <path strokeLinecap="round" strokeLinejoin="round" d={ICON_PATHS[variant]} />
    </svg>
  );
}

export function Callout({
  variant = 'info',
  title,
  icon,
  onClose,
  children,
  className,
}: CalloutProps) {
  const styles = variantStyles[variant];
  return (
    <div role="alert" className={cn('flex gap-3 rounded-2xl border px-4 py-3.5', styles.box, className)}>
      {icon !== false && (
        <span className={cn('mt-0.5 shrink-0', styles.icon)}>
          {icon ?? <DefaultIcon variant={variant} className="size-5" />}
        </span>
      )}
      <div className="min-w-0 flex-1">
        {title && <p className={cn('text-sm font-bold', styles.title)}>{title}</p>}
        {children && (
          <div className={cn('text-sm leading-relaxed text-slate-600', title ? 'mt-1' : undefined)}>{children}</div>
        )}
      </div>
      {onClose && (
        <button
          type="button"
          onClick={onClose}
          aria-label="닫기"
          className={cn(
            '-mr-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-black/5 hover:text-slate-600',
          )}
        >
          <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
            <path d="M18 6L6 18" />
            <path d="M6 6L18 18" />
          </svg>
        </button>
      )}
    </div>
  );
}