Web InnoventixFreeCode

Expandable Alert

Original · free

An alert that expands to reveal more detail.

byWeb InnoventixReact + Tailwind
alertexpandablealerts
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-expandable.json
alert-expandable.tsx
"use client";

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

type Tone = "info" | "success" | "warning" | "danger";

interface AlertItem {
  id: string;
  tone: Tone;
  title: string;
  summary: string;
  detail: ReactNode;
  meta: string;
}

const toneStyles: Record<
  Tone,
  {
    ring: string;
    bg: string;
    iconWrap: string;
    iconColor: string;
    title: string;
    accent: string;
    chip: string;
    icon: ReactNode;
  }
> = {
  info: {
    ring: "ring-sky-200/70 dark:ring-sky-500/25",
    bg: "bg-sky-50/80 dark:bg-sky-950/40",
    iconWrap: "bg-sky-100 dark:bg-sky-500/15",
    iconColor: "text-sky-600 dark:text-sky-300",
    title: "text-sky-950 dark:text-sky-100",
    accent: "bg-sky-500",
    chip: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-200",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M11.25 11.25h.75v4.5m-.75 0h1.5M12 8.25h.008v.008H12V8.25Z"
      />
    ),
  },
  success: {
    ring: "ring-emerald-200/70 dark:ring-emerald-500/25",
    bg: "bg-emerald-50/80 dark:bg-emerald-950/40",
    iconWrap: "bg-emerald-100 dark:bg-emerald-500/15",
    iconColor: "text-emerald-600 dark:text-emerald-300",
    title: "text-emerald-950 dark:text-emerald-100",
    accent: "bg-emerald-500",
    chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-200",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="m4.5 12.75 6 6 9-13.5"
      />
    ),
  },
  warning: {
    ring: "ring-amber-200/70 dark:ring-amber-500/25",
    bg: "bg-amber-50/80 dark:bg-amber-950/40",
    iconWrap: "bg-amber-100 dark:bg-amber-500/15",
    iconColor: "text-amber-600 dark:text-amber-300",
    title: "text-amber-950 dark:text-amber-100",
    accent: "bg-amber-500",
    chip: "bg-amber-100 text-amber-800 dark:bg-amber-500/15 dark:text-amber-200",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 9v3.75m0 3.75h.008M10.29 3.86 1.82 18a1.5 1.5 0 0 0 1.29 2.25h17.78A1.5 1.5 0 0 0 22.18 18L13.71 3.86a1.5 1.5 0 0 0-2.58 0Z"
      />
    ),
  },
  danger: {
    ring: "ring-rose-200/70 dark:ring-rose-500/25",
    bg: "bg-rose-50/80 dark:bg-rose-950/40",
    iconWrap: "bg-rose-100 dark:bg-rose-500/15",
    iconColor: "text-rose-600 dark:text-rose-300",
    title: "text-rose-950 dark:text-rose-100",
    accent: "bg-rose-500",
    chip: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-200",
    icon: (
      <path
        strokeLinecap="round"
        strokeLinejoin="round"
        d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
      />
    ),
  },
};

const alerts: AlertItem[] = [
  {
    id: "deploy",
    tone: "success",
    title: "Deploy #1482 shipped to production",
    summary: "All 214 checks passed. Rollout completed in 3m 12s.",
    meta: "2 min ago",
    detail: (
      <>
        <p>
          The <code className="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.8em] dark:bg-white/10">web-api</code>{" "}
          and <code className="rounded bg-black/5 px-1 py-0.5 font-mono text-[0.8em] dark:bg-white/10">edge-cache</code>{" "}
          services were rolled out to all three regions with zero failed health checks.
        </p>
        <ul className="mt-3 space-y-1.5">
          <li>us-east-1 &middot; 6 pods healthy</li>
          <li>eu-west-2 &middot; 4 pods healthy</li>
          <li>ap-south-1 &middot; 4 pods healthy</li>
        </ul>
      </>
    ),
  },
  {
    id: "usage",
    tone: "warning",
    title: "You've used 84% of your monthly API quota",
    summary: "At the current pace you'll hit the limit around July 26.",
    meta: "18 min ago",
    detail: (
      <>
        <p>
          You have <strong>160,000</strong> of 1,000,000 requests remaining this cycle. Requests over the
          limit are throttled to 10/second rather than rejected.
        </p>
        <p className="mt-3">
          Upgrading to the Scale plan raises the ceiling to 5M requests and removes the throttle
          entirely. The change takes effect immediately and is prorated.
        </p>
      </>
    ),
  },
  {
    id: "payment",
    tone: "danger",
    title: "We couldn't process your last payment",
    summary: "The card ending in 4291 was declined by the issuer.",
    meta: "1 hour ago",
    detail: (
      <>
        <p>
          Your subscription stays active until <strong>July 21</strong>. If the payment still fails
          after two more attempts, workspaces on this account will switch to read-only.
        </p>
        <p className="mt-3">
          The most common cause is an expired card or an issuer fraud hold. Updating your billing
          details will trigger an immediate retry.
        </p>
      </>
    ),
  },
];

function Chevron({ open }: { open: boolean }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      aria-hidden="true"
      className={`h-5 w-5 shrink-0 transition-transform duration-300 ease-out ${
        open ? "rotate-180" : "rotate-0"
      }`}
    >
      <path strokeLinecap="round" strokeLinejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
    </svg>
  );
}

function ExpandableAlert({ item }: { item: AlertItem }) {
  const [open, setOpen] = useState(false);
  const [dismissed, setDismissed] = useState(false);
  const regionId = useId();
  const s = toneStyles[item.tone];
  const bodyRef = useRef<HTMLDivElement>(null);

  if (dismissed) return null;

  const actionLabel =
    item.tone === "danger"
      ? "Update billing"
      : item.tone === "warning"
        ? "Upgrade plan"
        : "View details";

  return (
    <div
      role="alert"
      className={`relative overflow-hidden rounded-2xl ring-1 ${s.ring} ${s.bg} shadow-sm backdrop-blur transition-shadow duration-300 hover:shadow-md`}
    >
      <span className={`absolute inset-y-0 left-0 w-1 ${s.accent}`} aria-hidden="true" />

      <button
        type="button"
        onClick={() => setOpen((v) => !v)}
        aria-expanded={open}
        aria-controls={regionId}
        className="group flex w-full items-start gap-3.5 rounded-2xl px-4 py-4 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-slate-900/40 dark:focus-visible:ring-white/40 sm:px-5"
      >
        <span
          className={`mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-full ${s.iconWrap} ${s.iconColor}`}
        >
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.9} className="h-5 w-5">
            {s.icon}
          </svg>
        </span>

        <span className="min-w-0 flex-1">
          <span className="flex items-center gap-2">
            <span className={`truncate text-sm font-semibold sm:text-[0.95rem] ${s.title}`}>
              {item.title}
            </span>
          </span>
          <span className="mt-0.5 block text-sm text-slate-600 dark:text-slate-300/90">
            {item.summary}
          </span>
          <span className="mt-2 flex items-center gap-2">
            <span
              className={`inline-flex items-center rounded-full px-2 py-0.5 text-[0.7rem] font-medium ${s.chip}`}
            >
              {item.meta}
            </span>
            <span className="text-[0.7rem] font-medium text-slate-500 transition-colors group-hover:text-slate-700 dark:text-slate-400 dark:group-hover:text-slate-200">
              {open ? "Hide details" : "Show details"}
            </span>
          </span>
        </span>

        <span className={`mt-1 ${s.iconColor}`}>
          <Chevron open={open} />
        </span>
      </button>

      <div
        id={regionId}
        role="region"
        className="grid transition-[grid-template-rows] duration-300 ease-out"
        style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
      >
        <div className="overflow-hidden">
          <div
            ref={bodyRef}
            className={`aex-body px-4 pb-4 pl-16 pr-5 text-sm leading-relaxed text-slate-700 dark:text-slate-300 sm:pl-[4.6rem] ${
              open ? "aex-body-in" : ""
            }`}
          >
            <div className="rounded-xl border border-black/5 bg-white/60 p-4 dark:border-white/10 dark:bg-white/5">
              {item.detail}
            </div>
            <div className="mt-3 flex flex-wrap items-center gap-2.5">
              <button
                type="button"
                onClick={() => setDismissed(true)}
                className={`inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-xs font-semibold text-white shadow-sm outline-none transition-transform duration-150 hover:brightness-110 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-transparent active:scale-[0.97] ${s.accent}`}
              >
                {actionLabel}
              </button>
              <button
                type="button"
                onClick={() => setDismissed(true)}
                className="inline-flex items-center rounded-lg px-3 py-2 text-xs font-semibold text-slate-600 outline-none transition-colors hover:bg-black/5 focus-visible:ring-2 focus-visible:ring-slate-900/30 dark:text-slate-300 dark:hover:bg-white/10 dark:focus-visible:ring-white/30"
              >
                Dismiss
              </button>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

export default function AlertExpandable() {
  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 dark:bg-slate-950 sm:px-6 sm:py-20">
      <style>{`
        @keyframes aex-rise {
          from { opacity: 0; transform: translateY(6px); }
          to   { opacity: 1; transform: translateY(0); }
        }
        .aex-body { opacity: 0; }
        .aex-body-in { animation: aex-rise 360ms ease-out both; }
        @media (prefers-reduced-motion: reduce) {
          .aex-body,
          .aex-body-in { animation: none !important; opacity: 1 !important; transform: none !important; }
        }
      `}</style>

      <div className="mx-auto max-w-2xl">
        <div className="mb-8">
          <span className="inline-flex items-center rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-200">
            Notifications
          </span>
          <h2 className="mt-3 text-2xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
            You have 3 alerts to review
          </h2>
          <p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
            Tap any alert to expand the full context, then act on it or dismiss it.
          </p>
        </div>

        <div className="space-y-3.5">
          {alerts.map((item) => (
            <ExpandableAlert key={item.id} item={item} />
          ))}
        </div>
      </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 →