Overlay
Toast
Notificación flotante apilable con expiración
Preview
color
position
children
onDismiss
Code
import css from "./Toast.module.css";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@phosphor-icons/react/dist/csr/X";
type Color = "success" | "error" | "info";
type Position =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
interface ToastProps {
color: Color;
position?: Position;
dismissAriaLabel?: string;
children: React.ReactNode;
onDismiss?: () => void;
}
export default function Toast({
color,
position,
dismissAriaLabel,
children,
onDismiss,
}: ToastProps) {
// No <body> to portal into until the client has it; in flow there is nothing to wait for.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
// The toast is its own live region; a viewport stacking these must not wrap them in another.
const card = (
<div
className={`
${css.toast}
${css[color]}
${position ? `${css.float} ${css[position.replace("-", "_")]}` : ""}
`}
role={color === "error" ? "alert" : "status"}
style={
{ "--toast-color": `var(--color-${color})` } as React.CSSProperties
}
>
<div className={css.toast_content}>{children}</div>
{onDismiss && (
<button
type="button"
className={css.toast_dismiss}
aria-label={dismissAriaLabel}
onClick={onDismiss}
>
<XIcon weight="bold" />
</button>
)}
</div>
);
if (!position) return card;
if (!mounted) return null;
return createPortal(card, document.body);
}
.toast {
display: flex;
align-items: center;
gap: var(--space-12);
/* The height the dismiss button dictates (32px + padding), kept when there is none. */
min-height: var(--space-56);
padding: var(--space-12) var(--space-16);
border-radius: var(--radius-12);
font-family: var(--font-body);
font-size: var(--text-14);
font-weight: var(--weight-500);
/* Dark sinks the tone toward the page so light ink can sit on it; light takes the tone's own
fill. */
background: light-dark(
var(--toast-fill),
color-mix(in srgb, var(--toast-color) 42%, var(--color-bg))
);
color: light-dark(
color-mix(in srgb, var(--toast-color) 15%, black),
var(--color-text)
);
/* Tight layer draws the edge, wide layer the lift (what reads on dark). */
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 40%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 45%, transparent)
);
-webkit-tap-highlight-color: transparent;
.toast_content {
flex: 1;
line-height: var(--leading-normal);
}
.toast_dismiss {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-32);
height: var(--space-32);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-full);
color: inherit;
transition: background 0.2s ease;
svg {
width: 1.125em;
height: 1.125em;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, currentColor 15%, transparent);
}
}
&:active {
background: color-mix(in srgb, currentColor 30%, transparent);
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
}
/* Error also serves as form-validation TEXT, which caps its token, so it lightens itself
in oklch (sRGB would drain the chroma); success and info get a class too, so the lookup always
resolves. */
&.success,
&.info {
--toast-fill: var(--toast-color);
}
&.error {
--toast-fill: color-mix(in oklch, var(--toast-color) 75%, white);
}
&.float {
position: fixed;
/* Above the Modal overlay (9999): a notice must outlive a dialog opening over it. */
z-index: 10000;
width: min(400px, calc(100% - var(--space-48)));
&.top_left {
top: var(--space-24);
left: var(--space-24);
}
&.top_center {
top: var(--space-24);
left: 50%;
transform: translateX(-50%);
}
&.top_right {
top: var(--space-24);
right: var(--space-24);
}
&.bottom_left {
bottom: var(--space-24);
left: var(--space-24);
}
&.bottom_center {
bottom: var(--space-24);
left: 50%;
transform: translateX(-50%);
}
&.bottom_right {
bottom: var(--space-24);
right: var(--space-24);
}
}
}
import css from "./ToastProvider.module.css";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import Toast from "./Toast";
type Position =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
interface ToastOptions {
color: "success" | "error" | "info";
content: React.ReactNode;
duration?: number;
// No label, no button: an icon-only control cannot go unnamed, so the toast just times out.
dismissAriaLabel?: string;
}
interface ToastApi {
show: (options: ToastOptions) => () => void;
}
interface ToastProviderProps {
position: Position;
maxVisible: number;
children: React.ReactNode;
}
type Phase = "enter" | "shown" | "leave";
interface ToastEntry {
id: number;
options: ToastOptions;
phase: Phase;
}
interface ToastTimer {
remaining: number;
startedAt: number;
handle: number | null;
}
// MUST match the CSS `.slot` transition (0.26s) — an invisible coupling.
const DURATION = 260;
// Applied inline: it belongs to the FLIP pass, not to the enter/leave classes.
const REFLOW_MS = 320;
const REFLOW = `transform ${REFLOW_MS}ms cubic-bezier(0.22, 0.61, 0.36, 1)`;
const ToastContext = createContext<ToastApi | null>(null);
function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export default function ToastProvider({
position,
maxVisible,
children,
}: ToastProviderProps) {
const [entries, setEntries] = useState<ToastEntry[]>([]);
const slots = useRef(new Map<number, HTMLDivElement>());
const lastTops = useRef(new Map<number, number>());
const nextId = useRef(0);
const timers = useRef(new Map<number, ToastTimer>());
const leaving = useRef(new Set<number>());
const paused = useRef(false);
const clearTimer = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer && timer.handle !== null) clearTimeout(timer.handle);
timers.current.delete(id);
}, []);
const dismiss = useCallback(
(id: number) => {
if (leaving.current.has(id)) return;
leaving.current.add(id);
clearTimer(id);
// Pinned out of the flow at once, so the survivors slide into the gap while this one flies
// away.
const slot = slots.current.get(id);
if (slot) {
slot.style.top = `${slot.offsetTop}px`;
slot.style.left = `${slot.offsetLeft}px`;
slot.style.width = `${slot.offsetWidth}px`;
slot.style.height = `${slot.offsetHeight}px`;
slot.style.position = "absolute";
}
setEntries((prev) =>
prev.map((entry) =>
entry.id === id ? { ...entry, phase: "leave" } : entry,
),
);
window.setTimeout(
() => {
leaving.current.delete(id);
setEntries((prev) => prev.filter((entry) => entry.id !== id));
},
prefersReducedMotion() ? 0 : DURATION,
);
},
[clearTimer],
);
const startTimer = useCallback(
(id: number) => {
const timer = timers.current.get(id);
// Already ticking: a stray resume must not stack a second timeout on it.
if (!timer || timer.handle !== null) return;
timer.startedAt = Date.now();
timer.handle = window.setTimeout(() => dismiss(id), timer.remaining);
},
[dismiss],
);
const show = useCallback(
(options: ToastOptions) => {
const id = nextId.current++;
setEntries((prev) => [...prev, { id, options, phase: "enter" }]);
if (options.duration !== undefined) {
timers.current.set(id, {
remaining: options.duration,
startedAt: Date.now(),
handle: null,
});
if (!paused.current) startTimer(id);
}
return () => dismiss(id);
},
[dismiss, startTimer],
);
const pauseTimers = useCallback(() => {
paused.current = true;
const now = Date.now();
timers.current.forEach((timer) => {
if (timer.handle === null) return;
clearTimeout(timer.handle);
timer.handle = null;
timer.remaining = Math.max(0, timer.remaining - (now - timer.startedAt));
});
}, []);
const resumeTimers = useCallback(() => {
paused.current = false;
timers.current.forEach((_, id) => startTimer(id));
}, [startTimer]);
// The rAF gives the browser a "from" state to animate out of.
useEffect(() => {
if (!entries.some((entry) => entry.phase === "enter")) return;
const raf = requestAnimationFrame(() => {
setEntries((prev) =>
prev.map((entry) =>
entry.phase === "enter" ? { ...entry, phase: "shown" } : entry,
),
);
});
return () => cancelAnimationFrame(raf);
}, [entries]);
useEffect(() => {
const living = entries.filter((entry) => entry.phase !== "leave");
if (living.length > maxVisible) dismiss(living[0].id);
}, [entries, maxVisible, dismiss]);
// FLIP: measure where everyone landed, then replay them from where they were; only transforms
// animate.
useLayoutEffect(() => {
const tops = new Map<number, number>();
slots.current.forEach((slot, id) => {
if (!leaving.current.has(id)) tops.set(id, slot.offsetTop);
});
if (!prefersReducedMotion()) {
tops.forEach((top, id) => {
const slot = slots.current.get(id);
const previous = lastTops.current.get(id);
if (!slot || previous === undefined || previous === top) return;
slot.style.transition = "none";
slot.style.transform = `translateY(${previous - top}px)`;
// Flushes the "from": without it both writes coalesce into one recalc and it jumps.
void slot.offsetHeight;
requestAnimationFrame(() => {
slot.style.transition = REFLOW;
slot.style.transform = "";
// Handed back to its classes: leaving REFLOW inline would hijack the next exit.
window.setTimeout(() => {
slot.style.transition = "";
}, REFLOW_MS);
});
});
}
lastTops.current = tops;
}, [entries]);
useEffect(() => {
const pending = timers.current;
return () => {
pending.forEach((timer) => {
if (timer.handle !== null) clearTimeout(timer.handle);
});
pending.clear();
};
}, []);
const api = useMemo<ToastApi>(() => ({ show }), [show]);
// The newest toast hugs the screen edge: at the top it renders first, at the bottom the natural
// order does it.
const ordered = position.startsWith("top") ? [...entries].reverse() : entries;
return (
<ToastContext.Provider value={api}>
{children}
{entries.length > 0 &&
createPortal(
<div
className={`
${css.viewport}
${css[position.replace("-", "_")]}
`}
onMouseEnter={pauseTimers}
onMouseLeave={resumeTimers}
onFocus={pauseTimers}
onBlur={resumeTimers}
>
{ordered.map((entry) => (
<div
key={entry.id}
ref={(el) => {
if (el) {
slots.current.set(entry.id, el);
} else {
slots.current.delete(entry.id);
}
}}
className={`
${css.viewport_slot}
${entry.phase === "shown" ? css.shown : ""}
`}
>
<div className={css.slot_inner}>
<Toast
color={entry.options.color}
dismissAriaLabel={entry.options.dismissAriaLabel}
onDismiss={
entry.options.dismissAriaLabel
? () => dismiss(entry.id)
: undefined
}
>
{entry.options.content}
</Toast>
</div>
</div>
))}
</div>,
document.body,
)}
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const api = useContext(ToastContext);
if (!api) {
throw new Error("useToast must be used inside <ToastProvider>");
}
return api;
}
.viewport {
position: fixed;
/* Full height: a leaving toast is pinned to this box, which must never resize under it. */
inset-block: 0;
/* Above the Modal overlay (9999): a notice must outlive a dialog opening over it. */
z-index: 10000;
display: flex;
flex-direction: column;
width: min(400px, 100%);
padding: var(--space-24);
/* Only the cards take pointer events, or the edge-spanning box would swallow clicks. */
pointer-events: none;
.viewport_slot {
/* Base is the OFF state: dropping .shown replays it as the exit, with its
own timing and no second class. */
opacity: 0;
transform: translate(var(--toast-travel-x, 0), var(--toast-travel-y, 0));
transition:
opacity 0.26s ease-in,
transform 0.26s ease-in;
&.shown {
opacity: 1;
transform: none;
/* The entrance is the one that gets to show off: it overshoots and settles. */
transition:
opacity 0.42s ease-out,
transform 0.42s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-reduced-motion: reduce) {
transition: none;
&.shown {
transition: none;
}
}
.slot_inner {
/* The gap rides inside the toast it belongs to, so it leaves with it. */
padding-block-start: var(--space-12);
pointer-events: auto;
}
}
&.top_left,
&.top_center,
&.top_right {
justify-content: flex-start;
}
&.bottom_left,
&.bottom_center,
&.bottom_right {
justify-content: flex-end;
}
&.top_left,
&.bottom_left {
left: 0;
--toast-travel-x: -120%;
}
&.top_right,
&.bottom_right {
/* The Modal publishes the scrollbar width it hides; without it the stack would jump by that
much. */
right: var(--scrollbar-lock, 0px);
--toast-travel-x: 120%;
}
&.top_center,
&.bottom_center {
left: 50%;
transform: translateX(-50%);
}
&.top_center {
--toast-travel-y: -140%;
}
&.bottom_center {
--toast-travel-y: 140%;
}
}
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { PhX } from "@phosphor-icons/vue";
type Color = "success" | "error" | "info";
type Position =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
interface ToastProps {
color: Color;
position?: Position;
dismissAriaLabel?: string;
onDismiss?: () => void;
}
const { position } = defineProps<ToastProps>();
// No <body> to teleport into until the client has it; in flow there is nothing to wait for.
const mounted = ref(false);
onMounted(() => (mounted.value = true));
const positionClass = computed(() => position?.replace("-", "_"));
</script>
<template>
<Teleport to="body" :disabled="!position">
<!-- The toast is its own live region; a viewport stacking these must not wrap them in
another. -->
<div
v-if="!position || mounted"
class="toast"
:class="[color, position ? ['float', positionClass] : null]"
:role="color === 'error' ? 'alert' : 'status'"
:style="{ '--toast-color': `var(--color-${color})` }"
>
<div class="toast_content"><slot></slot></div>
<button
v-if="onDismiss"
type="button"
class="toast_dismiss"
:aria-label="dismissAriaLabel"
@click="onDismiss"
>
<PhX weight="bold" />
</button>
</div>
</Teleport>
</template>
<style scoped>
.toast {
display: flex;
align-items: center;
gap: var(--space-12);
/* The height the dismiss button dictates (32px + padding), kept when there is none. */
min-height: var(--space-56);
padding: var(--space-12) var(--space-16);
border-radius: var(--radius-12);
font-family: var(--font-body);
font-size: var(--text-14);
font-weight: var(--weight-500);
/* Dark sinks the tone toward the page so light ink can sit on it; light takes the tone's own
fill. */
background: light-dark(
var(--toast-fill),
color-mix(in srgb, var(--toast-color) 42%, var(--color-bg))
);
color: light-dark(
color-mix(in srgb, var(--toast-color) 15%, black),
var(--color-text)
);
/* Tight layer draws the edge, wide layer the lift (what reads on dark). */
box-shadow:
0 1px 2px
light-dark(
color-mix(in srgb, black 12%, transparent),
color-mix(in srgb, black 40%, transparent)
),
0 4px 16px
light-dark(
color-mix(in srgb, black 8%, transparent),
color-mix(in srgb, black 45%, transparent)
);
-webkit-tap-highlight-color: transparent;
.toast_content {
flex: 1;
line-height: var(--leading-normal);
}
.toast_dismiss {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--space-32);
height: var(--space-32);
border: none;
background: none;
cursor: pointer;
border-radius: var(--radius-full);
color: inherit;
transition: background 0.2s ease;
svg {
width: 1.125em;
height: 1.125em;
}
@media (hover: hover) {
&:hover {
background: color-mix(in srgb, currentColor 15%, transparent);
}
}
&:active {
background: color-mix(in srgb, currentColor 30%, transparent);
}
&:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
}
/* Error also serves as form-validation TEXT, which caps its token, so it lightens itself
in oklch (sRGB would drain the chroma); success and info get a class too, so the lookup always
resolves. */
&.success,
&.info {
--toast-fill: var(--toast-color);
}
&.error {
--toast-fill: color-mix(in oklch, var(--toast-color) 75%, white);
}
&.float {
position: fixed;
/* Above the Modal overlay (9999): a notice must outlive a dialog opening over it. */
z-index: 10000;
width: min(400px, calc(100% - var(--space-48)));
&.top_left {
top: var(--space-24);
left: var(--space-24);
}
&.top_center {
top: var(--space-24);
left: 50%;
transform: translateX(-50%);
}
&.top_right {
top: var(--space-24);
right: var(--space-24);
}
&.bottom_left {
bottom: var(--space-24);
left: var(--space-24);
}
&.bottom_center {
bottom: var(--space-24);
left: 50%;
transform: translateX(-50%);
}
&.bottom_right {
bottom: var(--space-24);
right: var(--space-24);
}
}
}
</style>
<script lang="ts">
import { inject, type InjectionKey, type VNode } from "vue";
type Position =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right";
interface ToastOptions {
color: "success" | "error" | "info";
content: VNode | VNode[];
duration?: number;
// No label, no button: an icon-only control cannot go unnamed, so the toast just times out.
dismissAriaLabel?: string;
}
interface ToastApi {
show: (options: ToastOptions) => () => void;
}
const toastKey: InjectionKey<ToastApi> = Symbol("toast");
export function useToast(): ToastApi {
const api = inject(toastKey, null);
if (!api) {
throw new Error("useToast must be used inside <ToastProvider>");
}
return api;
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, provide, shallowRef, watch } from "vue";
import Toast from "./Toast.vue";
type Phase = "enter" | "shown" | "leave";
interface ToastEntry {
id: number;
options: ToastOptions;
phase: Phase;
}
interface ToastTimer {
remaining: number;
startedAt: number;
handle: number | null;
}
interface ToastProviderProps {
position: Position;
maxVisible: number;
}
// MUST match the CSS `.slot` transition (0.26s) — an invisible coupling.
const DURATION = 260;
// Applied inline: it belongs to the FLIP pass, not to the enter/leave classes.
const REFLOW_MS = 320;
const REFLOW = `transform ${REFLOW_MS}ms cubic-bezier(0.22, 0.61, 0.36, 1)`;
const { position, maxVisible } = defineProps<ToastProviderProps>();
// Shallow: entries carry vnodes and callbacks; only the swap has to be reactive.
const entries = shallowRef<ToastEntry[]>([]);
const slots = new Map<number, HTMLElement>();
const lastTops = new Map<number, number>();
const timers = new Map<number, ToastTimer>();
const leaving = new Set<number>();
let nextId = 0;
let paused = false;
function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
const registerSlot = (id: number, el: Element | null) => {
if (el) {
slots.set(id, el as HTMLElement);
} else {
slots.delete(id);
}
};
const clearTimer = (id: number) => {
const timer = timers.get(id);
if (timer && timer.handle !== null) clearTimeout(timer.handle);
timers.delete(id);
};
const dismiss = (id: number) => {
if (leaving.has(id)) return;
leaving.add(id);
clearTimer(id);
// Pinned out of the flow at once, so the survivors slide into the gap while this one flies away.
const slot = slots.get(id);
if (slot) {
slot.style.top = `${slot.offsetTop}px`;
slot.style.left = `${slot.offsetLeft}px`;
slot.style.width = `${slot.offsetWidth}px`;
slot.style.height = `${slot.offsetHeight}px`;
slot.style.position = "absolute";
}
entries.value = entries.value.map((entry) =>
entry.id === id ? { ...entry, phase: "leave" } : entry,
);
window.setTimeout(
() => {
leaving.delete(id);
entries.value = entries.value.filter((entry) => entry.id !== id);
},
prefersReducedMotion() ? 0 : DURATION,
);
};
const startTimer = (id: number) => {
const timer = timers.get(id);
// Already ticking: a stray resume must not stack a second timeout on it.
if (!timer || timer.handle !== null) return;
timer.startedAt = Date.now();
timer.handle = window.setTimeout(() => dismiss(id), timer.remaining);
};
const show = (options: ToastOptions) => {
const id = nextId++;
entries.value = [...entries.value, { id, options, phase: "enter" }];
if (options.duration !== undefined) {
timers.set(id, {
remaining: options.duration,
startedAt: Date.now(),
handle: null,
});
if (!paused) startTimer(id);
}
return () => dismiss(id);
};
const pauseTimers = () => {
paused = true;
const now = Date.now();
timers.forEach((timer) => {
if (timer.handle === null) return;
clearTimeout(timer.handle);
timer.handle = null;
timer.remaining = Math.max(0, timer.remaining - (now - timer.startedAt));
});
};
const resumeTimers = () => {
paused = false;
timers.forEach((_, id) => startTimer(id));
};
// FLIP: measure where everyone landed, then replay them from where they were; only transforms
// animate.
// Hand-rolled, not <TransitionGroup>: its move-class needs a CSS rule React has no twin for.
watch(
entries,
() => {
const tops = new Map<number, number>();
slots.forEach((slot, id) => {
if (!leaving.has(id)) tops.set(id, slot.offsetTop);
});
if (!prefersReducedMotion()) {
tops.forEach((top, id) => {
const slot = slots.get(id);
const previous = lastTops.get(id);
if (!slot || previous === undefined || previous === top) return;
slot.style.transition = "none";
slot.style.transform = `translateY(${previous - top}px)`;
// Flushes the "from": without it both writes coalesce into one recalc and it jumps.
void slot.offsetHeight;
requestAnimationFrame(() => {
slot.style.transition = REFLOW;
slot.style.transform = "";
// Handed back to its classes: leaving REFLOW inline would hijack the next exit.
window.setTimeout(() => {
slot.style.transition = "";
}, REFLOW_MS);
});
});
}
lastTops.clear();
tops.forEach((top, id) => lastTops.set(id, top));
},
{ flush: "post" },
);
// The rAF gives the browser a "from" state to animate out of.
watch(
entries,
() => {
if (!entries.value.some((entry) => entry.phase === "enter")) return;
requestAnimationFrame(() => {
entries.value = entries.value.map((entry) =>
entry.phase === "enter" ? { ...entry, phase: "shown" } : entry,
);
});
},
{ flush: "post" },
);
watch(
entries,
() => {
const living = entries.value.filter((entry) => entry.phase !== "leave");
if (living.length > maxVisible) dismiss(living[0].id);
},
{ flush: "post" },
);
onBeforeUnmount(() => {
timers.forEach((timer) => {
if (timer.handle !== null) clearTimeout(timer.handle);
});
timers.clear();
});
provide(toastKey, { show });
const positionClass = computed(() => position.replace("-", "_"));
// The newest toast hugs the screen edge: at the top it renders first, at the bottom the natural
// order does it.
const ordered = computed(() =>
position.startsWith("top")
? [...entries.value].reverse()
: entries.value,
);
const Content = (slotProps: { nodes: VNode | VNode[] }) => slotProps.nodes;
</script>
<template>
<slot></slot>
<Teleport to="body">
<div
v-if="entries.length > 0"
class="viewport"
:class="positionClass"
@mouseenter="pauseTimers"
@mouseleave="resumeTimers"
@focusin="pauseTimers"
@focusout="resumeTimers"
>
<div
v-for="entry in ordered"
:key="entry.id"
:ref="(el) => registerSlot(entry.id, el as Element | null)"
class="viewport_slot"
:class="{ shown: entry.phase === 'shown' }"
>
<div class="slot_inner">
<Toast
:color="entry.options.color"
:dismissAriaLabel="entry.options.dismissAriaLabel"
:onDismiss="
entry.options.dismissAriaLabel
? () => dismiss(entry.id)
: undefined
"
>
<Content :nodes="entry.options.content" />
</Toast>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.viewport {
position: fixed;
/* Full height: a leaving toast is pinned to this box, which must never resize under it. */
inset-block: 0;
/* Above the Modal overlay (9999): a notice must outlive a dialog opening over it. */
z-index: 10000;
display: flex;
flex-direction: column;
width: min(400px, 100%);
padding: var(--space-24);
/* Only the cards take pointer events, or the edge-spanning box would swallow clicks. */
pointer-events: none;
.viewport_slot {
/* Base is the OFF state: dropping .shown replays it as the exit, with its
own timing and no second class. */
opacity: 0;
transform: translate(var(--toast-travel-x, 0), var(--toast-travel-y, 0));
transition:
opacity 0.26s ease-in,
transform 0.26s ease-in;
&.shown {
opacity: 1;
transform: none;
/* The entrance is the one that gets to show off: it overshoots and settles. */
transition:
opacity 0.42s ease-out,
transform 0.42s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-reduced-motion: reduce) {
transition: none;
&.shown {
transition: none;
}
}
.slot_inner {
/* The gap rides inside the toast it belongs to, so it leaves with it. */
padding-block-start: var(--space-12);
pointer-events: auto;
}
}
&.top_left,
&.top_center,
&.top_right {
justify-content: flex-start;
}
&.bottom_left,
&.bottom_center,
&.bottom_right {
justify-content: flex-end;
}
&.top_left,
&.bottom_left {
left: 0;
--toast-travel-x: -120%;
}
&.top_right,
&.bottom_right {
/* The Modal publishes the scrollbar width it hides; without it the stack would jump by that
much. */
right: var(--scrollbar-lock, 0px);
--toast-travel-x: 120%;
}
&.top_center,
&.bottom_center {
left: 50%;
transform: translateX(-50%);
}
&.top_center {
--toast-travel-y: -140%;
}
&.bottom_center {
--toast-travel-y: 140%;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| color | "success" | "error" | "info" | required | Estado semántico que tiñe el toast con la paleta de estado de Foundations. error se anuncia con role="alert" y el resto con role="status". |
| position | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" | — | Esquina o centro donde flota por su cuenta; sin ella la tarjeta se renderiza en el flujo (es como ToastProvider la apila). |
| dismissAriaLabel | string | — | Junto a onDismiss: nombre del botón de cierre para lectores de pantalla. |
| children | React.ReactNode | required | Contenido de la notificación. |
| onDismiss | () => void | — | Callback de la X; sin él no se dibuja. |
Imperative API
Azúcar opcional sobre el <Toast> declarativo. Envuelve tu app en ToastProvider y lánzalo con una llamada.
púlsalo varias veces
position
duration
color
dismissAriaLabel
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| position | "top-left" | "top-center" | "top-right" | "bottom-left" | "bottom-center" | "bottom-right" | required | Prop de ToastProvider. Esquina o centro donde vive la pila. |
| maxVisible | number | required | Prop de ToastProvider. Tope de toasts visibles; al superarse, el más antiguo se desahucia. |
| color | "success" | "error" | "info" | required | Estado semántico que tiñe el toast con la paleta de estado de Foundations. error se anuncia con role="alert" y el resto con role="status". |
| content | React.ReactNode | required | Contenido de la notificación. |
| duration | number | — | Milisegundos hasta autoexpirar; sin ella no expira y solo lo retiran la X, el dismisser o maxVisible. |
| dismissAriaLabel | string | — | Nombre del botón de cierre para lectores de pantalla; sin él no se dibuja la X. |
| → show() | () => void | — | Devuelve el dismisser del toast lanzado; llamarlo lo retira (idempotente). |