Web InnoventixFreeCode

Slide In Toast

Original · free

slide-in corner toasts

byWeb InnoventixReact + Tailwind
toastslideintoasts
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-slide-in.json
toast-slide-in.tsx
"use client";

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

type ToastVariant = "success" | "error" | "warning" | "info";
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";

interface ToastData {
  id: number;
  variant: ToastVariant;
  title: string;
  message: string;
  duration: number;
}

interface VariantStyle {
  label: string;
  role: "status" | "alert";
  live: "polite" | "assertive";
  iconWrap: string;
  cardRing: string;
  bar: string;
  trigger: string;
  icon: ReactNode;
}

const CheckIcon = (
  <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
    <path
      d="M20 6.5 9.5 17 4 11.5"
      stroke="currentColor"
      strokeWidth="2.2"
      strokeLinecap="round"
      strokeLinejoin="round"
    />
  </svg>
);

const AlertIcon = (
  <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
    <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.2" />
    <path d="M12 7.5v5.2" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
    <circle cx="12" cy="16.4" r="1.15" fill="currentColor" />
  </svg>
);

const WarnIcon = (
  <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
    <path
      d="M12 3.6 22 20H2L12 3.6Z"
      stroke="currentColor"
      strokeWidth="2.1"
      strokeLinejoin="round"
    />
    <path d="M12 9.6v4.4" stroke="currentColor" strokeWidth="2.1" strokeLinecap="round" />
    <circle cx="12" cy="16.9" r="1.1" fill="currentColor" />
  </svg>
);

const InfoIcon = (
  <svg viewBox="0 0 24 24" fill="none" className="h-5 w-5" aria-hidden="true">
    <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.2" />
    <path d="M12 11v5.2" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
    <circle cx="12" cy="7.7" r="1.15" fill="currentColor" />
  </svg>
);

const VARIANTS: Record<ToastVariant, VariantStyle> = {
  success: {
    label: "Success",
    role: "status",
    live: "polite",
    iconWrap: "bg-emerald-100 text-emerald-700 dark:bg-emerald-400/15 dark:text-emerald-300",
    cardRing: "ring-emerald-200/70 dark:ring-emerald-400/20",
    bar: "bg-emerald-500 dark:bg-emerald-400",
    trigger:
      "border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-200 dark:hover:bg-emerald-400/20 focus-visible:ring-emerald-500",
    icon: CheckIcon,
  },
  error: {
    label: "Error",
    role: "alert",
    live: "assertive",
    iconWrap: "bg-rose-100 text-rose-700 dark:bg-rose-400/15 dark:text-rose-300",
    cardRing: "ring-rose-200/70 dark:ring-rose-400/20",
    bar: "bg-rose-500 dark:bg-rose-400",
    trigger:
      "border-rose-300 bg-rose-50 text-rose-800 hover:bg-rose-100 dark:border-rose-400/25 dark:bg-rose-400/10 dark:text-rose-200 dark:hover:bg-rose-400/20 focus-visible:ring-rose-500",
    icon: AlertIcon,
  },
  warning: {
    label: "Warning",
    role: "alert",
    live: "assertive",
    iconWrap: "bg-amber-100 text-amber-700 dark:bg-amber-400/15 dark:text-amber-300",
    cardRing: "ring-amber-200/70 dark:ring-amber-400/20",
    bar: "bg-amber-500 dark:bg-amber-400",
    trigger:
      "border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-200 dark:hover:bg-amber-400/20 focus-visible:ring-amber-500",
    icon: WarnIcon,
  },
  info: {
    label: "Info",
    role: "status",
    live: "polite",
    iconWrap: "bg-sky-100 text-sky-700 dark:bg-sky-400/15 dark:text-sky-300",
    cardRing: "ring-sky-200/70 dark:ring-sky-400/20",
    bar: "bg-sky-500 dark:bg-sky-400",
    trigger:
      "border-sky-300 bg-sky-50 text-sky-800 hover:bg-sky-100 dark:border-sky-400/25 dark:bg-sky-400/10 dark:text-sky-200 dark:hover:bg-sky-400/20 focus-visible:ring-sky-500",
    icon: InfoIcon,
  },
};

const MESSAGES: Record<ToastVariant, { title: string; message: string }[]> = {
  success: [
    { title: "Deployment live", message: "Build 4.2.1 shipped to production in 38 seconds." },
    { title: "Invite sent", message: "We emailed a workspace invite to dana@northwind.io." },
    { title: "Backup complete", message: "12,480 files archived to the Frankfurt region." },
  ],
  error: [
    { title: "Payment declined", message: "The card ending in 4242 was declined. Try another method." },
    { title: "Upload failed", message: "report-q3.pdf is 41 MB and exceeds the 25 MB limit." },
    { title: "Sync error", message: "Couldn't reach the server. Retrying in 30 seconds." },
  ],
  warning: [
    { title: "Storage almost full", message: "You've used 94% of your 50 GB plan." },
    { title: "Session expiring", message: "You'll be signed out in 2 minutes for inactivity." },
    { title: "Unsaved changes", message: "Three edits in the Q3 roadmap aren't saved yet." },
  ],
  info: [
    { title: "New comment", message: "Priya mentioned you in the Q3 roadmap review." },
    { title: "Update available", message: "Version 5.0 is ready. Restart to apply the changes." },
    { title: "Milestone reached", message: "Your project just passed 1,000 stars on the registry." },
  ],
};

const CORNERS: { value: Corner; label: string }[] = [
  { value: "top-left", label: "Top left" },
  { value: "top-right", label: "Top right" },
  { value: "bottom-left", label: "Bottom left" },
  { value: "bottom-right", label: "Bottom right" },
];

const DURATIONS: { value: number; label: string }[] = [
  { value: 3000, label: "3s" },
  { value: 5000, label: "5s" },
  { value: 8000, label: "8s" },
];

const CORNER_CLASS: Record<Corner, string> = {
  "top-left": "top-4 left-4 items-start",
  "top-right": "top-4 right-4 items-end",
  "bottom-left": "bottom-4 left-4 items-start",
  "bottom-right": "bottom-4 right-4 items-end",
};

const MAX_TOASTS = 5;

const focusRing =
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950";

function ToastCard({
  toast,
  onDismiss,
  dir,
  reduce,
}: {
  toast: ToastData;
  onDismiss: (id: number) => void;
  dir: number;
  reduce: boolean;
}) {
  const style = VARIANTS[toast.variant];
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const paused = hovered || focused;

  const remainingRef = useRef(toast.duration);
  const startRef = useRef(0);
  const timerRef = useRef<number | null>(null);

  useEffect(() => {
    if (paused) {
      if (timerRef.current !== null) {
        window.clearTimeout(timerRef.current);
        timerRef.current = null;
      }
      remainingRef.current -= Date.now() - startRef.current;
      return;
    }
    startRef.current = Date.now();
    timerRef.current = window.setTimeout(() => onDismiss(toast.id), remainingRef.current);
    return () => {
      if (timerRef.current !== null) {
        window.clearTimeout(timerRef.current);
        timerRef.current = null;
      }
    };
  }, [paused, toast.id, onDismiss]);

  const handleBlur = (e: FocusEvent<HTMLdiv>) => {
    if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
    setFocused(false);
  };

  const enter = reduce
    ? { opacity: 0 }
    : { opacity: 0, x: dir * 68, scale: 0.9 };
  const center = reduce ? { opacity: 1 } : { opacity: 1, x: 0, scale: 1 };
  const leave = reduce
    ? { opacity: 0 }
    : { opacity: 0, x: dir * 68, scale: 0.86 };

  return (
    <motion.div
      layout
      initial={enter}
      animate={center}
      exit={leave}
      transition={{
        layout: reduce
          ? { duration: 0.15 }
          : { type: "spring", stiffness: 520, damping: 42 },
        default: reduce
          ? { duration: 0.16 }
          : { type: "spring", stiffness: 420, damping: 32 },
      }}
      role={style.role}
      aria-live={style.live}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onFocus={() => setFocused(true)}
      onBlur={handleBlur}
      className={`pointer-events-auto relative w-[21rem] max-w-[calc(100vw-3rem)] overflow-hidden rounded-2xl bg-white p-4 shadow-xl shadow-slate-900/10 ring-1 dark:bg-slate-900 dark:shadow-black/40 ${style.cardRing}`}
    >
      <span
        aria-hidden="true"
        className="tsi-shine pointer-events-none absolute inset-y-0 -left-1/3 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-white/60 to-transparent dark:via-white/10"
      />
      <div className="flex items-start gap-3">
        <span
          className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ${style.iconWrap}`}
        >
          {style.icon}
        </span>
        <div className="min-w-0 flex-1">
          <p className="text-sm font-semibold text-slate-900 dark:text-slate-50">
            {toast.title}
          </p>
          <p className="mt-0.5 text-sm leading-snug text-slate-600 dark:text-slate-300">
            {toast.message}
          </p>
        </div>
        <button
          type="button"
          onClick={() => onDismiss(toast.id)}
          aria-label={`Dismiss ${style.label.toLowerCase()} notification: ${toast.title}`}
          className={`-mr-1 -mt-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200 focus-visible:ring-slate-400 ${focusRing}`}
        >
          <svg viewBox="0 0 24 24" fill="none" className="h-4 w-4" aria-hidden="true">
            <path
              d="M6 6l12 12M18 6 6 18"
              stroke="currentColor"
              strokeWidth="2.2"
              strokeLinecap="round"
            />
          </svg>
        </button>
      </div>
      <div className="mt-3 h-1 w-full overflow-hidden rounded-full bg-slate-200/70 dark:bg-slate-700/60">
        <div
          className={`tsi-progress-bar h-full w-full origin-left rounded-full ${style.bar}`}
          style={{
            animation: `tsi-progress ${toast.duration}ms linear forwards`,
            animationPlayState: paused ? "paused" : "running",
          }}
        />
      </div>
    </motion.div>
  );
}

export default function ToastSlideIn() {
  const prefersReduced = useReducedMotion();
  const reduce = prefersReduced ?? false;

  const [toasts, setToasts] = useState<ToastData[]>([]);
  const [position, setPosition] = useState<Corner>("bottom-right");
  const [duration, setDuration] = useState(5000);
  const idRef = useRef(1);

  const addToast = useCallback(
    (variant: ToastVariant) => {
      const pool = MESSAGES[variant];
      const pick = pool[Math.floor(Math.random() * pool.length)];
      const id = idRef.current++;
      setToasts((prev) => {
        const next: ToastData[] = [...prev, { id, variant, ...pick, duration }];
        return next.slice(-MAX_TOASTS);
      });
    },
    [duration],
  );

  const dismiss = useCallback((id: number) => {
    setToasts((prev) => prev.filter((t) => t.id !== id));
  }, []);

  const clearAll = useCallback(() => setToasts([]), []);

  const isTop = position === "top-left" || position === "top-right";
  const dir = position === "top-left" || position === "bottom-left" ? -1 : 1;
  const ordered = isTop ? [...toasts].reverse() : toasts;

  const cornerLabel =
    CORNERS.find((c) => c.value === position)?.label.toLowerCase() ?? "corner";

  return (
    <section className="relative w-full overflow-hidden bg-white px-6 py-20 text-slate-900 sm:px-10 dark:bg-slate-950 dark:text-slate-50">
      <style>{`
        @keyframes tsi-progress {
          from { transform: scaleX(1); }
          to { transform: scaleX(0); }
        }
        @keyframes tsi-shine {
          0% { transform: translateX(-40%) skewX(-12deg); opacity: 0; }
          22% { opacity: 1; }
          100% { transform: translateX(1000%) skewX(-12deg); opacity: 0; }
        }
        .tsi-shine {
          animation: tsi-shine 1100ms ease-out 1;
        }
        @media (prefers-reduced-motion: reduce) {
          .tsi-shine { animation: none !important; opacity: 0 !important; }
          .tsi-progress-bar { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 [background-image:linear-gradient(to_right,rgba(100,116,139,0.06)_1px,transparent_1px),linear-gradient(to_bottom,rgba(100,116,139,0.06)_1px,transparent_1px)] [background-size:34px_34px]"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium uppercase tracking-wider text-slate-500 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
            Notification system
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Slide-in corner toasts
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-300">
            Fire a notification and watch it glide into the{" "}
            <span className="font-medium text-slate-900 dark:text-slate-100">{cornerLabel}</span>{" "}
            corner. Toasts stack, auto-dismiss with a live countdown, and pause the moment you hover
            or focus one. Everything is keyboard reachable.
          </p>
        </header>

        <div className="mt-10 grid gap-6 lg:grid-cols-[320px_1fr]">
          <div className="flex flex-col gap-6 rounded-2xl border border-slate-200 bg-slate-50/70 p-5 dark:border-slate-800 dark:bg-slate-900/50">
            <fieldset>
              <legend className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Trigger a notification
              </legend>
              <div className="mt-3 grid grid-cols-2 gap-2">
                {(Object.keys(VARIANTS) as ToastVariant[]).map((v) => (
                  <button
                    key={v}
                    type="button"
                    onClick={() => addToast(v)}
                    className={`inline-flex items-center justify-center gap-2 rounded-xl border px-3 py-2.5 text-sm font-medium transition-colors ${focusRing} ${VARIANTS[v].trigger}`}
                  >
                    <span className="[&>svg]:h-4 [&>svg]:w-4">{VARIANTS[v].icon}</span>
                    {VARIANTS[v].label}
                  </button>
                ))}
              </div>
            </fieldset>

            <fieldset>
              <legend className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Corner
              </legend>
              <div className="mt-3 grid grid-cols-2 gap-2">
                {CORNERS.map((c) => (
                  <label key={c.value} className="relative cursor-pointer">
                    <input
                      type="radio"
                      name="tsi-corner"
                      value={c.value}
                      checked={position === c.value}
                      onChange={() => setPosition(c.value)}
                      className="peer sr-only"
                    />
                    <span
                      className={`flex items-center justify-center rounded-xl border border-slate-200 bg-white px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors peer-hover:border-slate-300 peer-checked:border-indigo-500 peer-checked:bg-indigo-50 peer-checked:text-indigo-700 peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:peer-checked:border-indigo-400 dark:peer-checked:bg-indigo-400/15 dark:peer-checked:text-indigo-200 dark:peer-focus-visible:ring-offset-slate-950`}
                    >
                      {c.label}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            <fieldset>
              <legend className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Auto-dismiss
              </legend>
              <div className="mt-3 grid grid-cols-3 gap-2">
                {DURATIONS.map((d) => (
                  <label key={d.value} className="relative cursor-pointer">
                    <input
                      type="radio"
                      name="tsi-duration"
                      value={d.value}
                      checked={duration === d.value}
                      onChange={() => setDuration(d.value)}
                      className="peer sr-only"
                    />
                    <span
                      className={`flex items-center justify-center rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium tabular-nums text-slate-600 transition-colors peer-hover:border-slate-300 peer-checked:border-violet-500 peer-checked:bg-violet-50 peer-checked:text-violet-700 peer-focus-visible:ring-2 peer-focus-visible:ring-violet-500 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:peer-checked:border-violet-400 dark:peer-checked:bg-violet-400/15 dark:peer-checked:text-violet-200 dark:peer-focus-visible:ring-offset-slate-950`}
                    >
                      {d.label}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>

            <div className="flex items-center justify-between border-t border-slate-200 pt-4 dark:border-slate-800">
              <span className="text-sm text-slate-500 dark:text-slate-400" aria-live="polite">
                {toasts.length} active
                <span className="text-slate-400 dark:text-slate-500">
                  {" "}/ {MAX_TOASTS} max
                </span>
              </span>
              <button
                type="button"
                onClick={clearAll}
                disabled={toasts.length === 0}
                className={`inline-flex items-center gap-1.5 rounded-lg border border-slate-300 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 focus-visible:ring-slate-400 ${focusRing}`}
              >
                Clear all
              </button>
            </div>
          </div>

          <div className="relative min-h-[26rem] overflow-hidden rounded-2xl border border-dashed border-slate-300 bg-gradient-to-br from-slate-50 to-white dark:border-slate-700 dark:from-slate-900/60 dark:to-slate-950">
            <div className="pointer-events-none absolute inset-0 flex items-center justify-center px-6 text-center">
              <p className="max-w-xs text-sm text-slate-400 dark:text-slate-600">
                Preview stage — toasts slide into the{" "}
                <span className="font-medium text-slate-500 dark:text-slate-400">{cornerLabel}</span>{" "}
                corner of this panel.
              </p>
            </div>

            <div
              role="region"
              aria-label="Notifications"
              className={`pointer-events-none absolute z-10 flex w-[21rem] max-w-[calc(100%-2rem)] flex-col gap-3 ${CORNER_CLASS[position]}`}
            >
              <AnimatePresence initial={false} mode="popLayout">
                {ordered.map((t) => (
                  <ToastCard
                    key={t.id}
                    toast={t}
                    onDismiss={dismiss}
                    dir={dir}
                    reduce={reduce}
                  />
                ))}
              </AnimatePresence>
            </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 →