Web InnoventixFreeCode

Callout Alert

Original · free

An editorial callout box in info, tip and warning styles.

byWeb InnoventixReact + Tailwind
alertcalloutalerts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/alert-callout.json
alert-callout.tsx
"use client";

import { useState, type ReactNode } from "react";

type Variant = "info" | "tip" | "warning" | "danger";

type Callout = {
  variant: Variant;
  title: string;
  body: ReactNode;
  dismissible?: boolean;
};

const VARIANTS: Record<
  Variant,
  {
    label: string;
    container: string;
    icon: string;
    title: string;
    body: string;
    accent: string;
    ring: string;
    glyph: ReactNode;
  }
> = {
  info: {
    label: "Note",
    container:
      "border-sky-200/80 bg-sky-50/70 dark:border-sky-400/25 dark:bg-sky-950/40",
    icon: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
    title: "text-sky-900 dark:text-sky-100",
    body: "text-sky-800/90 dark:text-sky-200/80",
    accent: "bg-sky-500 dark:bg-sky-400",
    ring: "focus-visible:ring-sky-500/60",
    glyph: (
      <path
        d="M12 8h.01M11 12h1v4h1"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    ),
  },
  tip: {
    label: "Tip",
    container:
      "border-emerald-200/80 bg-emerald-50/70 dark:border-emerald-400/25 dark:bg-emerald-950/40",
    icon: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
    title: "text-emerald-900 dark:text-emerald-100",
    body: "text-emerald-800/90 dark:text-emerald-200/80",
    accent: "bg-emerald-500 dark:bg-emerald-400",
    ring: "focus-visible:ring-emerald-500/60",
    glyph: (
      <path
        d="M9 18h6m-5 3h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.2 1 2v.1h5v-.1c0-.8.4-1.5 1-2A6 6 0 0 0 12 3Z"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    ),
  },
  warning: {
    label: "Warning",
    container:
      "border-amber-200/80 bg-amber-50/70 dark:border-amber-400/25 dark:bg-amber-950/40",
    icon: "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-300",
    title: "text-amber-900 dark:text-amber-100",
    body: "text-amber-800/90 dark:text-amber-200/80",
    accent: "bg-amber-500 dark:bg-amber-400",
    ring: "focus-visible:ring-amber-500/60",
    glyph: (
      <path
        d="M12 9v4m0 3h.01M10.3 4.3 2.6 17.5A2 2 0 0 0 4.3 20.5h15.4a2 2 0 0 0 1.7-3L13.7 4.3a2 2 0 0 0-3.4 0Z"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    ),
  },
  danger: {
    label: "Important",
    container:
      "border-rose-200/80 bg-rose-50/70 dark:border-rose-400/25 dark:bg-rose-950/40",
    icon: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
    title: "text-rose-900 dark:text-rose-100",
    body: "text-rose-800/90 dark:text-rose-200/80",
    accent: "bg-rose-500 dark:bg-rose-400",
    ring: "focus-visible:ring-rose-500/60",
    glyph: (
      <path
        d="M12 8v5m0 3h.01M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    ),
  },
};

const CALLOUTS: Callout[] = [
  {
    variant: "info",
    title: "Deploys run on the main branch only",
    body: (
      <>
        Pushes to feature branches build a preview but never touch production.
        Merge into <code className="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10">main</code>{" "}
        to promote a preview to the live site.
      </>
    ),
  },
  {
    variant: "tip",
    title: "Prefetch the next route on hover",
    body: (
      <>
        Wrap navigation links so the target page&rsquo;s data starts loading the
        moment a pointer enters. Users perceive the jump as instant, and you
        spend nothing extra on bots that never hover.
      </>
    ),
    dismissible: true,
  },
  {
    variant: "warning",
    title: "This endpoint is rate-limited",
    body: (
      <>
        You can call <code className="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10">/v1/search</code>{" "}
        up to 60 times per minute per API key. Batch your queries or cache
        results before you hit the ceiling and start receiving{" "}
        <code className="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.85em] dark:bg-white/10">429</code> responses.
      </>
    ),
    dismissible: true,
  },
  {
    variant: "danger",
    title: "Deleting a workspace is permanent",
    body: (
      <>
        Every project, member invite and stored secret is removed immediately
        and cannot be recovered. Export anything you need before you confirm.
      </>
    ),
  },
];

function CalloutBox({
  data,
  onDismiss,
}: {
  data: Callout;
  onDismiss?: () => void;
}) {
  const v = VARIANTS[data.variant];

  return (
    <div
      role="note"
      aria-label={v.label}
      className={`fcode-callout relative flex gap-4 overflow-hidden rounded-2xl border p-5 pl-6 shadow-sm ${v.container}`}
    >
      <span
        aria-hidden="true"
        className={`absolute inset-y-0 left-0 w-1.5 ${v.accent}`}
      />

      <span
        aria-hidden="true"
        className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${v.icon}`}
      >
        <svg
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth={1.8}
          className="h-5 w-5"
        >
          {v.glyph}
        </svg>
      </span>

      <div className="min-w-0 flex-1">
        <div className="flex items-start justify-between gap-3">
          <div className="min-w-0">
            <span
              className={`text-[0.7rem] font-semibold uppercase tracking-[0.14em] ${v.body}`}
            >
              {v.label}
            </span>
            <h3
              className={`mt-0.5 text-[0.95rem] font-semibold leading-snug ${v.title}`}
            >
              {data.title}
            </h3>
          </div>

          {data.dismissible && onDismiss ? (
            <button
              type="button"
              onClick={onDismiss}
              aria-label={`Dismiss ${v.label.toLowerCase()}: ${data.title}`}
              className={`-mr-1 -mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-current/70 outline-none transition hover:bg-black/5 hover:text-current focus-visible:ring-2 dark:hover:bg-white/10 ${v.ring}`}
            >
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                className="h-4 w-4"
                aria-hidden="true"
              >
                <path
                  d="M6 6l12 12M18 6 6 18"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </button>
          ) : null}
        </div>

        <div className={`mt-1.5 text-sm leading-relaxed ${v.body}`}>
          {data.body}
        </div>
      </div>
    </div>
  );
}

export default function AlertCallout() {
  const [dismissed, setDismissed] = useState<Set<number>>(new Set());

  const dismiss = (i: number) =>
    setDismissed((prev) => {
      const next = new Set(prev);
      next.add(i);
      return next;
    });

  const hasReset = CALLOUTS.some((c, i) => c.dismissible && dismissed.has(i));

  return (
    <section className="relative w-full bg-white px-4 py-16 text-slate-900 sm:px-6 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes fcodeCallout_in {
          from { opacity: 0; transform: translateY(10px) scale(0.985); }
          to   { opacity: 1; transform: translateY(0) scale(1); }
        }
        .fcode-callout {
          animation: fcodeCallout_in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
        }
        @media (prefers-reduced-motion: reduce) {
          .fcode-callout { animation: none; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-10 text-center">
          <span className="inline-block rounded-full border border-slate-200 px-3 py-1 text-xs font-medium uppercase tracking-widest text-slate-500 dark:border-slate-800 dark:text-slate-400">
            Documentation
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Callouts &amp; editorial notes
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm text-slate-500 dark:text-slate-400">
            Four semantic styles to draw attention to context, advice, caution
            and irreversible actions.
          </p>
        </div>

        <div className="flex flex-col gap-4">
          {CALLOUTS.map((c, i) =>
            c.dismissible && dismissed.has(i) ? null : (
              <CalloutBox
                key={i}
                data={c}
                onDismiss={c.dismissible ? () => dismiss(i) : undefined}
              />
            )
          )}
        </div>

        {hasReset ? (
          <div className="mt-6 flex justify-center">
            <button
              type="button"
              onClick={() => setDismissed(new Set())}
              className="rounded-full border border-slate-200 px-4 py-2 text-xs font-medium text-slate-600 outline-none transition hover:border-slate-300 hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-slate-400/60 dark:border-slate-800 dark:text-slate-300 dark:hover:border-slate-700 dark:hover:bg-slate-900"
            >
              Restore dismissed callouts
            </button>
          </div>
        ) : null}
      </div>
    </section>
  );
}

Dependencies

None (React + Tailwind only).

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →