Slide In Toast
Original · freeslide-in corner toasts
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"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
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 quoteSimilar components
Browse all →
Basic Toast
Originalbasic toast notifications

Variants Toast
Originalsuccess/info/warning/error toasts
Icon Toast
Originaltoasts with icon and title

Progress Toast
Originaltoast with auto-dismiss progress

Action Toast
Originaltoast with an undo action

Stacked Toast
Originalstacked toast queue

Promise Toast
Originalloading-to-result promise toast

Glass Toast
Originalglassmorphic toasts

Position Toast
Originaltoast position picker demo

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

