Web InnoventixFreeCode

Position Toast

Original · free

toast position picker demo

byWeb InnoventixReact + Tailwind
toastpositiontoasts
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/toast-position.json
toast-position.tsx
"use client";

import {
  useEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type ToastType = "success" | "error" | "info" | "warning";

type Position =
  | "top-left"
  | "top-center"
  | "top-right"
  | "bottom-left"
  | "bottom-center"
  | "bottom-right";

interface ToastItem {
  id: number;
  type: ToastType;
  title: string;
  description: string;
  duration: number;
}

interface TimerRecord {
  handle: ReturnType<typeof setTimeout> | undefined;
  remaining: number;
  startedAt: number;
}

const MAX_TOASTS = 4;

const POSITIONS: Position[] = [
  "top-left",
  "top-center",
  "top-right",
  "bottom-left",
  "bottom-center",
  "bottom-right",
];

const TYPES: ToastType[] = ["success", "error", "info", "warning"];

const POSITION_LABEL: Record<Position, string> = {
  "top-left": "Top left",
  "top-center": "Top center",
  "top-right": "Top right",
  "bottom-left": "Bottom left",
  "bottom-center": "Bottom center",
  "bottom-right": "Bottom right",
};

const CELL_ALIGN: Record<Position, string> = {
  "top-left": "items-start justify-start",
  "top-center": "items-start justify-center",
  "top-right": "items-start justify-end",
  "bottom-left": "items-end justify-start",
  "bottom-center": "items-end justify-center",
  "bottom-right": "items-end justify-end",
};

const ANCHOR_CLASS: Record<Position, string> = {
  "top-left": "top-3 left-3 items-start",
  "top-center": "top-3 left-1/2 -translate-x-1/2 items-center",
  "top-right": "top-3 right-3 items-end",
  "bottom-left": "bottom-3 left-3 items-start flex-col-reverse",
  "bottom-center": "bottom-3 left-1/2 -translate-x-1/2 items-center flex-col-reverse",
  "bottom-right": "bottom-3 right-3 items-end flex-col-reverse",
};

const CheckIcon: ReactNode = (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <circle cx="12" cy="12" r="9" />
    <path d="m8.5 12.2 2.4 2.4 4.6-5" />
  </svg>
);

const AlertIcon: ReactNode = (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <circle cx="12" cy="12" r="9" />
    <path d="M12 8v5" />
    <path d="M12 16.5h.01" />
  </svg>
);

const InfoIcon: ReactNode = (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <circle cx="12" cy="12" r="9" />
    <path d="M12 11.5v5" />
    <path d="M12 7.5h.01" />
  </svg>
);

const WarnIcon: ReactNode = (
  <svg
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={2}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    <path d="M10.3 3.9 1.9 18a2 2 0 0 0 1.7 3h16.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
    <path d="M12 9v4.5" />
    <path d="M12 17.5h.01" />
  </svg>
);

const TYPE_META: Record<
  ToastType,
  { label: string; role: "status" | "alert"; chip: string; bar: string; icon: ReactNode }
> = {
  success: {
    label: "Success",
    role: "status",
    chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    icon: CheckIcon,
  },
  error: {
    label: "Error",
    role: "alert",
    chip: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
    bar: "bg-rose-500 dark:bg-rose-400",
    icon: AlertIcon,
  },
  info: {
    label: "Info",
    role: "status",
    chip: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
    bar: "bg-sky-500 dark:bg-sky-400",
    icon: InfoIcon,
  },
  warning: {
    label: "Warning",
    role: "alert",
    chip: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
    bar: "bg-amber-500 dark:bg-amber-400",
    icon: WarnIcon,
  },
};

const MESSAGES: Record<ToastType, { title: string; description: string }[]> = {
  success: [
    { title: "Payment received", description: "Invoice #4021 was charged to Visa ending 4242." },
    { title: "Deploy succeeded", description: "v2.8.1 is live on production — shipped in 41 seconds." },
    { title: "Saved to workspace", description: "Your edits to Q3-roadmap.md are synced across devices." },
  ],
  error: [
    { title: "Upload failed", description: "product-hero.webp exceeded the 8 MB attachment limit." },
    { title: "Payment declined", description: "Card ending 1180 was rejected by the issuing bank." },
    { title: "Build broke", description: "3 type errors in checkout/session.ts — open logs to review." },
  ],
  info: [
    { title: "Sync in progress", description: "Reconnecting to the Frankfurt (eu-central-1) region." },
    { title: "New reply", description: "Priya commented on the pricing-page thread." },
    { title: "Export ready", description: "Your 12,480-row CSV is ready to download." },
  ],
  warning: [
    { title: "Storage almost full", description: "You've used 92% of your 50 GB plan." },
    { title: "Session expiring", description: "You'll be signed out after 5 minutes of inactivity." },
    { title: "Rate limit near", description: "312 of 500 API calls used this hour." },
  ],
};

export default function ToastPosition() {
  const prefersReduced = useReducedMotion();

  const [position, setPosition] = useState<Position>("bottom-right");
  const [type, setType] = useState<ToastType>("success");
  const [durationKey, setDurationKey] = useState<string>("5000");
  const [toasts, setToasts] = useState<ToastItem[]>([]);
  const [paused, setPaused] = useState(false);

  const idRef = useRef(0);
  const msgIndexRef = useRef(0);
  const pausedRef = useRef(false);
  const timers = useRef<Map<number, TimerRecord>>(new Map());
  const posRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const typeRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const duration = durationKey === "persist" ? Number.POSITIVE_INFINITY : Number(durationKey);

  useEffect(() => {
    const active = timers.current;
    return () => {
      active.forEach((t) => {
        if (t.handle) clearTimeout(t.handle);
      });
      active.clear();
    };
  }, []);

  const dismiss = (id: number) => {
    const t = timers.current.get(id);
    if (t && t.handle) clearTimeout(t.handle);
    timers.current.delete(id);
    setToasts((prev) => prev.filter((x) => x.id !== id));
  };

  const scheduleTimer = (id: number, ms: number) => {
    if (!Number.isFinite(ms)) return;
    if (pausedRef.current) {
      timers.current.set(id, { handle: undefined, remaining: ms, startedAt: Date.now() });
      return;
    }
    const handle = setTimeout(() => dismiss(id), ms);
    timers.current.set(id, { handle, remaining: ms, startedAt: Date.now() });
  };

  const pauseTimers = () => {
    if (pausedRef.current) return;
    pausedRef.current = true;
    const now = Date.now();
    timers.current.forEach((t) => {
      if (t.handle) clearTimeout(t.handle);
      t.remaining = Math.max(0, t.remaining - (now - t.startedAt));
      t.handle = undefined;
    });
    setPaused(true);
  };

  const resumeTimers = () => {
    if (!pausedRef.current) return;
    pausedRef.current = false;
    const now = Date.now();
    timers.current.forEach((t, id) => {
      t.startedAt = now;
      t.handle = setTimeout(() => dismiss(id), t.remaining);
    });
    setPaused(false);
  };

  const showToast = () => {
    const id = idRef.current + 1;
    idRef.current = id;
    const pack = MESSAGES[type];
    const msg = pack[msgIndexRef.current % pack.length];
    msgIndexRef.current += 1;
    const item: ToastItem = {
      id,
      type,
      title: msg.title,
      description: msg.description,
      duration,
    };
    setToasts((prev) => {
      const next = [item, ...prev];
      next.slice(MAX_TOASTS).forEach((o) => {
        const t = timers.current.get(o.id);
        if (t && t.handle) clearTimeout(t.handle);
        timers.current.delete(o.id);
      });
      return next.slice(0, MAX_TOASTS);
    });
    scheduleTimer(id, duration);
  };

  const clearAll = () => {
    timers.current.forEach((t) => {
      if (t.handle) clearTimeout(t.handle);
    });
    timers.current.clear();
    pausedRef.current = false;
    setPaused(false);
    setToasts([]);
  };

  const onPositionKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const col = i % 3;
    const row = Math.floor(i / 3);
    let nextCol = col;
    let nextRow = row;
    switch (e.key) {
      case "ArrowRight":
        nextCol = (col + 1) % 3;
        break;
      case "ArrowLeft":
        nextCol = (col + 2) % 3;
        break;
      case "ArrowUp":
        nextRow = 0;
        break;
      case "ArrowDown":
        nextRow = 1;
        break;
      case "Home":
        nextCol = 0;
        nextRow = 0;
        break;
      case "End":
        nextCol = 2;
        nextRow = 1;
        break;
      default:
        return;
    }
    e.preventDefault();
    const nextIndex = nextRow * 3 + nextCol;
    setPosition(POSITIONS[nextIndex]);
    posRefs.current[nextIndex]?.focus();
  };

  const onTypeKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const last = TYPES.length - 1;
    let next = i;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowDown":
        next = i === last ? 0 : i + 1;
        break;
      case "ArrowLeft":
      case "ArrowUp":
        next = i === 0 ? last : i - 1;
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = last;
        break;
      default:
        return;
    }
    e.preventDefault();
    setType(TYPES[next]);
    typeRefs.current[next]?.focus();
  };

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:py-20 lg:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes tpProgress {
          from { transform: scaleX(1); }
          to { transform: scaleX(0); }
        }
        @keyframes tpPulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.35; transform: scale(0.7); }
        }
        .tp-progress {
          width: 100%;
          transform-origin: left;
          animation-name: tpProgress;
          animation-timing-function: linear;
          animation-fill-mode: forwards;
        }
        .tp-live-dot {
          animation: tpPulse 1.8s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .tp-progress,
          .tp-live-dot {
            animation: none !important;
          }
        }
      `}</style>

      <div className="mx-auto max-w-6xl">
        <header className="mb-10 max-w-2xl">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
            Notifications
          </p>
          <h2 className="mt-3 text-3xl font-bold tracking-tight sm:text-4xl">
            Toast position picker
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Pick a corner, choose a style, then fire a notification into the live preview. Toasts
            stack, auto-dismiss, and pause while you hover — everything is keyboard operable.
          </p>
        </header>

        <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
          {/* Controls */}
          <div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm lg:col-span-2 dark:border-slate-800 dark:bg-slate-900">
            <div className="space-y-6">
              {/* Position */}
              <div>
                <div className="mb-2 flex items-baseline justify-between">
                  <span className="text-sm font-semibold">Position</span>
                  <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
                    {POSITION_LABEL[position]}
                  </span>
                </div>
                <div
                  role="radiogroup"
                  aria-label="Toast position"
                  className="grid aspect-[16/9] grid-cols-3 grid-rows-2 gap-2 rounded-xl border-2 border-dashed border-slate-200 bg-slate-50 p-2 dark:border-slate-700 dark:bg-slate-950/40"
                >
                  {POSITIONS.map((p, i) => {
                    const selected = position === p;
                    return (
                      <button
                        key={p}
                        type="button"
                        role="radio"
                        aria-checked={selected}
                        aria-label={POSITION_LABEL[p]}
                        tabIndex={selected ? 0 : -1}
                        ref={(el) => {
                          posRefs.current[i] = el;
                        }}
                        onClick={() => setPosition(p)}
                        onKeyDown={(e) => onPositionKey(e, i)}
                        className={`group flex ${CELL_ALIGN[p]} rounded-lg border p-2 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                          selected
                            ? "border-indigo-500 bg-indigo-50 dark:border-indigo-400/70 dark:bg-indigo-500/10"
                            : "border-slate-200 bg-white hover:border-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:hover:border-slate-600"
                        }`}
                      >
                        <span
                          className={`h-1.5 w-6 rounded-full transition ${
                            selected
                              ? "bg-indigo-500 dark:bg-indigo-400"
                              : "bg-slate-300 group-hover:bg-slate-400 dark:bg-slate-600 dark:group-hover:bg-slate-500"
                          }`}
                        />
                      </button>
                    );
                  })}
                </div>
              </div>

              {/* Style */}
              <div>
                <span className="mb-2 block text-sm font-semibold">Style</span>
                <div role="radiogroup" aria-label="Toast style" className="flex flex-wrap gap-2">
                  {TYPES.map((tp, i) => {
                    const meta = TYPE_META[tp];
                    const selected = type === tp;
                    return (
                      <button
                        key={tp}
                        type="button"
                        role="radio"
                        aria-checked={selected}
                        tabIndex={selected ? 0 : -1}
                        ref={(el) => {
                          typeRefs.current[i] = el;
                        }}
                        onClick={() => setType(tp)}
                        onKeyDown={(e) => onTypeKey(e, i)}
                        className={`inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                          selected
                            ? "border-indigo-500 bg-indigo-50 text-slate-900 dark:border-indigo-400/70 dark:bg-indigo-500/10 dark:text-white"
                            : "border-slate-200 bg-white text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
                        }`}
                      >
                        <span
                          className={`inline-flex h-6 w-6 items-center justify-center rounded-md [&_svg]:h-4 [&_svg]:w-4 ${meta.chip}`}
                        >
                          {meta.icon}
                        </span>
                        {meta.label}
                      </button>
                    );
                  })}
                </div>
              </div>

              {/* Duration */}
              <div>
                <label htmlFor="tp-duration" className="mb-2 block text-sm font-semibold">
                  Duration
                </label>
                <select
                  id="tp-duration"
                  value={durationKey}
                  onChange={(e) => setDurationKey(e.target.value)}
                  className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-700 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:focus-visible:ring-offset-slate-900"
                >
                  <option value="3000">3 seconds</option>
                  <option value="5000">5 seconds</option>
                  <option value="8000">8 seconds</option>
                  <option value="persist">Until dismissed</option>
                </select>
              </div>

              {/* Actions */}
              <div className="flex flex-col gap-3 sm:flex-row">
                <button
                  type="button"
                  onClick={showToast}
                  className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-500 active:bg-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
                >
                  <svg
                    className="h-4 w-4"
                    viewBox="0 0 24 24"
                    fill="none"
                    stroke="currentColor"
                    strokeWidth={2}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    aria-hidden="true"
                  >
                    <path d="M6 8a6 6 0 0 1 12 0c0 6.5 2.5 8.5 2.5 8.5H3.5S6 14.5 6 8" />
                    <path d="M10.2 20.5a2 2 0 0 0 3.6 0" />
                  </svg>
                  Show toast
                </button>
                <button
                  type="button"
                  onClick={clearAll}
                  disabled={toasts.length === 0}
                  className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                >
                  Clear
                  {toasts.length > 0 ? ` (${toasts.length})` : ""}
                </button>
              </div>

              <p className="text-xs text-slate-500 dark:text-slate-400">
                <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
                  Arrow keys
                </kbd>{" "}
                move the grid selection,{" "}
                <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-mono text-[10px] text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
                  Enter
                </kbd>{" "}
                fires the toast.
              </p>
            </div>
          </div>

          {/* Preview */}
          <div className="lg:col-span-3">
            <div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
              {/* Browser chrome */}
              <div className="flex items-center gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3 dark:border-slate-800 dark:bg-slate-900">
                <div className="flex items-center gap-1.5" aria-hidden="true">
                  <span className="h-3 w-3 rounded-full bg-rose-400" />
                  <span className="h-3 w-3 rounded-full bg-amber-400" />
                  <span className="h-3 w-3 rounded-full bg-emerald-400" />
                </div>
                <div className="flex-1 truncate rounded-md bg-white px-3 py-1 text-center text-xs text-slate-500 ring-1 ring-slate-200 dark:bg-slate-950 dark:text-slate-400 dark:ring-slate-700">
                  app.northwind.dev/dashboard
                </div>
                <div className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
                  <span className="tp-live-dot h-2 w-2 rounded-full bg-emerald-500 dark:bg-emerald-400" />
                  Live
                </div>
              </div>

              {/* Stage */}
              <div className="relative h-[420px] overflow-hidden bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-950">
                <div
                  aria-hidden="true"
                  className="pointer-events-none absolute inset-0 text-slate-300 opacity-50 dark:text-slate-700 dark:opacity-40"
                  style={{
                    backgroundImage: "radial-gradient(currentColor 1px, transparent 1px)",
                    backgroundSize: "22px 22px",
                  }}
                />

                {/* Mock app content */}
                <div
                  aria-hidden="true"
                  className="absolute inset-0 flex flex-col items-center justify-center gap-4 p-8"
                >
                  <div className="w-full max-w-sm rounded-xl border border-slate-200 bg-white/70 p-5 shadow-sm backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/50">
                    <div className="mb-4 flex items-center gap-3">
                      <div className="h-9 w-9 rounded-lg bg-slate-200 dark:bg-slate-700" />
                      <div className="space-y-1.5">
                        <div className="h-2.5 w-28 rounded-full bg-slate-200 dark:bg-slate-700" />
                        <div className="h-2 w-20 rounded-full bg-slate-100 dark:bg-slate-800" />
                      </div>
                    </div>
                    <div className="space-y-2.5">
                      <div className="h-2.5 w-full rounded-full bg-slate-100 dark:bg-slate-800" />
                      <div className="h-2.5 w-5/6 rounded-full bg-slate-100 dark:bg-slate-800" />
                      <div className="h-2.5 w-4/6 rounded-full bg-slate-100 dark:bg-slate-800" />
                    </div>
                  </div>
                  <p className="text-xs font-medium text-slate-400 dark:text-slate-500">
                    Your app UI — toasts render on top
                  </p>
                </div>

                {/* Toast stack */}
                <ul
                  aria-label="Notifications"
                  className={`pointer-events-none absolute z-10 flex w-[min(20rem,calc(100%-1.5rem))] list-none flex-col gap-2.5 ${ANCHOR_CLASS[position]}`}
                >
                  <AnimatePresence initial={false}>
                    {toasts.map((t) => {
                      const meta = TYPE_META[t.type];
                      const enterY = position.startsWith("top") ? -18 : 18;
                      return (
                        <motion.li
                          key={t.id}
                          layout={!prefersReduced}
                          initial={
                            prefersReduced
                              ? { opacity: 0 }
                              : { opacity: 0, y: enterY, scale: 0.97 }
                          }
                          animate={
                            prefersReduced
                              ? { opacity: 1 }
                              : { opacity: 1, y: 0, scale: 1 }
                          }
                          exit={
                            prefersReduced
                              ? { opacity: 0 }
                              : { opacity: 0, y: enterY, scale: 0.96 }
                          }
                          transition={{
                            duration: prefersReduced ? 0.14 : 0.32,
                            ease: [0.22, 1, 0.36, 1],
                          }}
                          onMouseEnter={pauseTimers}
                          onMouseLeave={resumeTimers}
                          onFocus={pauseTimers}
                          onBlur={resumeTimers}
                          role={meta.role}
                          aria-atomic="true"
                          className="pointer-events-auto relative w-full overflow-hidden rounded-xl border border-slate-200 bg-white/95 shadow-lg backdrop-blur dark:border-slate-700 dark:bg-slate-900/95"
                        >
                          <div className="flex items-start gap-3 p-3.5">
                            <span
                              className={`mt-0.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg [&_svg]:h-5 [&_svg]:w-5 ${meta.chip}`}
                            >
                              {meta.icon}
                            </span>
                            <div className="min-w-0 flex-1">
                              <p className="text-sm font-semibold text-slate-900 dark:text-white">
                                {t.title}
                              </p>
                              <p className="mt-0.5 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
                                {t.description}
                              </p>
                            </div>
                            <button
                              type="button"
                              onClick={() => dismiss(t.id)}
                              aria-label={`Dismiss ${meta.label.toLowerCase()}: ${t.title}`}
                              className="-mr-1 -mt-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-white dark:hover:bg-slate-800 dark:hover:text-slate-200 dark:focus-visible:ring-offset-slate-900"
                            >
                              <svg
                                className="h-4 w-4"
                                viewBox="0 0 24 24"
                                fill="none"
                                stroke="currentColor"
                                strokeWidth={2}
                                strokeLinecap="round"
                                strokeLinejoin="round"
                                aria-hidden="true"
                              >
                                <path d="M18 6 6 18" />
                                <path d="m6 6 12 12" />
                              </svg>
                            </button>
                          </div>
                          {Number.isFinite(t.duration) ? (
                            <span
                              aria-hidden="true"
                              className="absolute inset-x-0 bottom-0 block h-1 overflow-hidden"
                            >
                              <span
                                className={`tp-progress block h-full ${meta.bar}`}
                                style={{
                                  animationDuration: `${t.duration}ms`,
                                  animationPlayState: paused ? "paused" : "running",
                                }}
                              />
                            </span>
                          ) : null}
                        </motion.li>
                      );
                    })}
                  </AnimatePresence>
                </ul>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

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 →