Toast
Hoja clienteA transient, non-blocking notification with an imperative API.
Vista previa en vivo
Renderizado desde el código fuente instalado de VEXA — los mismos archivos que recibes.
Playground
Ajusta las props y copia el JSX equivalente.
Playground
toast({ title: "Changes saved", description: "Your workspace is up to date." })Instalación
Añade el código del componente a tu proyecto. Las dependencias del registry se resuelven automáticamente.
npx shadcn@latest add https://vexa.valfiguer.com/r/toast.jsonSe instala en components/vexa/toast.tsx
Dependencias npm
- @radix-ui/react-toast
- class-variance-authority
Dependencias del registry
- @vexa/utils
Otros items de VEXA que necesita este componente. Instálalos primero — la CLI los resuelve automáticamente.
Código
Cópialo a su ruta destino, o instálalo con el comando de arriba.
"use client";
import * as React from "react";
import * as ToastPrimitive from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/* -------------------------------------------------------------------------- */
/* Primitives — thin, token-styled wrappers over Radix Toast */
/* -------------------------------------------------------------------------- */
function CloseIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
className={className}
>
<path
d="M6 6l12 12M18 6 6 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
export function ToastProvider(
props: React.ComponentProps<typeof ToastPrimitive.Provider>,
) {
return <ToastPrimitive.Provider {...props} />;
}
export function ToastViewport({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Viewport>) {
return (
<ToastPrimitive.Viewport
data-slot="toast-viewport"
className={cn(
"fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col gap-2 p-4 sm:max-w-sm",
className,
)}
{...props}
/>
);
}
const toastVariants = cva(
[
"group pointer-events-auto relative flex w-full items-start justify-between gap-3",
"overflow-hidden rounded-vexa-md border p-4 pr-8 shadow-vexa-lg",
"transition-[opacity,transform] duration-[var(--vexa-duration-base)] ease-[var(--vexa-ease-standard)] motion-reduce:transition-none",
"data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
"data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none",
"data-[swipe=cancel]:translate-x-0",
"data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]",
],
{
variants: {
variant: {
default:
"border-vexa-border bg-vexa-surface-raised text-vexa-foreground",
success:
"border-vexa-success/30 bg-vexa-success/10 text-vexa-foreground",
danger: "border-vexa-danger/30 bg-vexa-danger/10 text-vexa-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export function Toast({
className,
variant,
...props
}: React.ComponentProps<typeof ToastPrimitive.Root> &
VariantProps<typeof toastVariants>) {
return (
<ToastPrimitive.Root
data-slot="toast"
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
}
export function ToastTitle({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Title>) {
return (
<ToastPrimitive.Title
data-slot="toast-title"
className={cn("text-sm font-medium", className)}
{...props}
/>
);
}
export function ToastDescription({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Description>) {
return (
<ToastPrimitive.Description
data-slot="toast-description"
className={cn("text-sm text-vexa-muted-foreground", className)}
{...props}
/>
);
}
export function ToastAction({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Action>) {
return (
<ToastPrimitive.Action
data-slot="toast-action"
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-vexa-sm border border-vexa-border bg-transparent px-3 text-sm font-medium",
"transition-colors duration-[var(--vexa-duration-fast)] hover:bg-vexa-accent",
"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background",
className,
)}
{...props}
/>
);
}
export function ToastClose({
className,
...props
}: React.ComponentProps<typeof ToastPrimitive.Close>) {
return (
<ToastPrimitive.Close
data-slot="toast-close"
className={cn(
"absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-vexa-sm text-vexa-muted-foreground",
"transition-colors duration-[var(--vexa-duration-fast)] hover:bg-vexa-accent hover:text-vexa-foreground",
"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring",
className,
)}
{...props}
>
<CloseIcon className="size-4" />
<span className="sr-only">Close</span>
</ToastPrimitive.Close>
);
}
/* -------------------------------------------------------------------------- */
/* Store — a tiny reducer + hook so `toast()` can be called imperatively */
/* -------------------------------------------------------------------------- */
type ToasterToast = React.ComponentProps<typeof Toast> & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: React.ReactNode;
};
const TOAST_LIMIT = 3;
const TOAST_REMOVE_DELAY = 400;
type State = { toasts: ToasterToast[] };
type Action =
| { type: "ADD_TOAST"; toast: ToasterToast }
| { type: "UPDATE_TOAST"; toast: Partial<ToasterToast> & { id: string } }
| { type: "DISMISS_TOAST"; toastId?: string }
| { type: "REMOVE_TOAST"; toastId?: string };
let count = 0;
function genId(): string {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
const listeners = new Set<(state: State) => void>();
let memoryState: State = { toasts: [] };
const removeTimers = new Map<string, ReturnType<typeof setTimeout>>();
function scheduleRemoval(toastId: string): void {
if (removeTimers.has(toastId)) return;
const timer = setTimeout(() => {
removeTimers.delete(toastId);
dispatch({ type: "REMOVE_TOAST", toastId });
}, TOAST_REMOVE_DELAY);
removeTimers.set(toastId, timer);
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "ADD_TOAST":
return {
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
for (const t of state.toasts) {
if (toastId === undefined || t.id === toastId) scheduleRemoval(t.id);
}
return {
toasts: state.toasts.map((t) =>
toastId === undefined || t.id === toastId ? { ...t, open: false } : t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) return { toasts: [] };
return { toasts: state.toasts.filter((t) => t.id !== action.toastId) };
}
}
function dispatch(action: Action): void {
memoryState = reducer(memoryState, action);
for (const listener of listeners) listener(memoryState);
}
export type ToastOptions = Omit<ToasterToast, "id">;
/**
* Imperatively enqueue a toast. Returns handles to update or dismiss it.
* Requires a single `<Toaster />` mounted in the tree.
*/
export function toast(options: ToastOptions) {
const id = genId();
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
const update = (next: Partial<ToasterToast>) =>
dispatch({ type: "UPDATE_TOAST", toast: { ...next, id } });
dispatch({
type: "ADD_TOAST",
toast: {
...options,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
});
return { id, dismiss, update };
}
/**
* Subscribe to the toast store. Use inside `Toaster`, or anywhere you need to
* enqueue toasts (`const { toast } = useToast()`).
*/
export function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.add(setState);
setState(memoryState);
return () => {
listeners.delete(setState);
};
}, []);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
/* -------------------------------------------------------------------------- */
/* Toaster — mount once; renders the live region and queued toasts */
/* -------------------------------------------------------------------------- */
/**
* Toaster — the toast host. Mount exactly one, typically in a client providers
* component near the root. Radix Toast supplies the `role`/`aria-live` region,
* hover-to-pause, swipe-to-dismiss, and F8-to-focus behaviors.
*/
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title ? <ToastTitle>{title}</ToastTitle> : null}
{description ? (
<ToastDescription>{description}</ToastDescription>
) : null}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);
}
JSON del registryr/toast.json
{
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
"name": "toast",
"type": "registry:ui",
"title": "Toast",
"description": "A transient, non-blocking notification with an imperative API.",
"dependencies": [
"@radix-ui/react-toast",
"class-variance-authority"
],
"registryDependencies": [
"@vexa/utils"
],
"files": [
{
"path": "registry/default/ui/toast.tsx",
"type": "registry:ui",
"target": "components/vexa/toast.tsx",
"content": "\"use client\";\n\nimport * as React from \"react\";\nimport * as ToastPrimitive from \"@radix-ui/react-toast\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\n\nimport { cn } from \"@/lib/utils\";\n\n/* -------------------------------------------------------------------------- */\n/* Primitives — thin, token-styled wrappers over Radix Toast */\n/* -------------------------------------------------------------------------- */\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n viewBox=\"0 0 24 24\"\n fill=\"none\"\n aria-hidden=\"true\"\n className={className}\n >\n <path\n d=\"M6 6l12 12M18 6 6 18\"\n stroke=\"currentColor\"\n strokeWidth=\"2\"\n strokeLinecap=\"round\"\n />\n </svg>\n );\n}\n\nexport function ToastProvider(\n props: React.ComponentProps<typeof ToastPrimitive.Provider>,\n) {\n return <ToastPrimitive.Provider {...props} />;\n}\n\nexport function ToastViewport({\n className,\n ...props\n}: React.ComponentProps<typeof ToastPrimitive.Viewport>) {\n return (\n <ToastPrimitive.Viewport\n data-slot=\"toast-viewport\"\n className={cn(\n \"fixed bottom-0 right-0 z-[100] flex max-h-screen w-full flex-col gap-2 p-4 sm:max-w-sm\",\n className,\n )}\n {...props}\n />\n );\n}\n\nconst toastVariants = cva(\n [\n \"group pointer-events-auto relative flex w-full items-start justify-between gap-3\",\n \"overflow-hidden rounded-vexa-md border p-4 pr-8 shadow-vexa-lg\",\n \"transition-[opacity,transform] duration-[var(--vexa-duration-base)] ease-[var(--vexa-ease-standard)] motion-reduce:transition-none\",\n \"data-[state=closed]:opacity-0 data-[state=open]:opacity-100\",\n \"data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none\",\n \"data-[swipe=cancel]:translate-x-0\",\n \"data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]\",\n ],\n {\n variants: {\n variant: {\n default:\n \"border-vexa-border bg-vexa-surface-raised text-vexa-foreground\",\n success:\n \"border-vexa-success/30 bg-vexa-success/10 text-vexa-foreground\",\n danger: \"border-vexa-danger/30 bg-vexa-danger/10 text-vexa-foreground\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n },\n);\n\nexport function Toast({\n className,\n variant,\n ...props\n}: React.ComponentProps<typeof ToastPrimitive.Root> &\n VariantProps<typeof toastVariants>) {\n return (\n <ToastPrimitive.Root\n data-slot=\"toast\"\n className={cn(toastVariants({ variant }), className)}\n {...props}\n />\n );\n}\n\nexport function ToastTitle({\n className,\n ...props\n}: React.ComponentProps<typeof ToastPrimitive.Title>) {\n return (\n <ToastPrimitive.Title\n data-slot=\"toast-title\"\n className={cn(\"text-sm font-medium\", className)}\n {...props}\n />\n );\n}\n\nexport function ToastDescription({\n className,\n ...props\n}: React.ComponentProps<typeof ToastPrimitive.Description>) {\n return (\n <ToastPrimitive.Description\n data-slot=\"toast-description\"\n className={cn(\"text-sm text-vexa-muted-foreground\", className)}\n {...props}\n />\n );\n}\n\nexport function ToastAction({\n className,\n ...props\n}: React.ComponentProps<typeof ToastPrimitive.Action>) {\n return (\n <ToastPrimitive.Action\n data-slot=\"toast-action\"\n className={cn(\n \"inline-flex h-8 shrink-0 items-center justify-center rounded-vexa-sm border border-vexa-border bg-transparent px-3 text-sm font-medium\",\n \"transition-colors duration-[var(--vexa-duration-fast)] hover:bg-vexa-accent\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring focus-visible:ring-offset-2 focus-visible:ring-offset-vexa-background\",\n className,\n )}\n {...props}\n />\n );\n}\n\nexport function ToastClose({\n className,\n ...props\n}: React.ComponentProps<typeof ToastPrimitive.Close>) {\n return (\n <ToastPrimitive.Close\n data-slot=\"toast-close\"\n className={cn(\n \"absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-vexa-sm text-vexa-muted-foreground\",\n \"transition-colors duration-[var(--vexa-duration-fast)] hover:bg-vexa-accent hover:text-vexa-foreground\",\n \"outline-none focus-visible:ring-2 focus-visible:ring-vexa-ring\",\n className,\n )}\n {...props}\n >\n <CloseIcon className=\"size-4\" />\n <span className=\"sr-only\">Close</span>\n </ToastPrimitive.Close>\n );\n}\n\n/* -------------------------------------------------------------------------- */\n/* Store — a tiny reducer + hook so `toast()` can be called imperatively */\n/* -------------------------------------------------------------------------- */\n\ntype ToasterToast = React.ComponentProps<typeof Toast> & {\n id: string;\n title?: React.ReactNode;\n description?: React.ReactNode;\n action?: React.ReactNode;\n};\n\nconst TOAST_LIMIT = 3;\nconst TOAST_REMOVE_DELAY = 400;\n\ntype State = { toasts: ToasterToast[] };\n\ntype Action =\n | { type: \"ADD_TOAST\"; toast: ToasterToast }\n | { type: \"UPDATE_TOAST\"; toast: Partial<ToasterToast> & { id: string } }\n | { type: \"DISMISS_TOAST\"; toastId?: string }\n | { type: \"REMOVE_TOAST\"; toastId?: string };\n\nlet count = 0;\nfunction genId(): string {\n count = (count + 1) % Number.MAX_SAFE_INTEGER;\n return count.toString();\n}\n\nconst listeners = new Set<(state: State) => void>();\nlet memoryState: State = { toasts: [] };\nconst removeTimers = new Map<string, ReturnType<typeof setTimeout>>();\n\nfunction scheduleRemoval(toastId: string): void {\n if (removeTimers.has(toastId)) return;\n const timer = setTimeout(() => {\n removeTimers.delete(toastId);\n dispatch({ type: \"REMOVE_TOAST\", toastId });\n }, TOAST_REMOVE_DELAY);\n removeTimers.set(toastId, timer);\n}\n\nfunction reducer(state: State, action: Action): State {\n switch (action.type) {\n case \"ADD_TOAST\":\n return {\n toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),\n };\n case \"UPDATE_TOAST\":\n return {\n toasts: state.toasts.map((t) =>\n t.id === action.toast.id ? { ...t, ...action.toast } : t,\n ),\n };\n case \"DISMISS_TOAST\": {\n const { toastId } = action;\n for (const t of state.toasts) {\n if (toastId === undefined || t.id === toastId) scheduleRemoval(t.id);\n }\n return {\n toasts: state.toasts.map((t) =>\n toastId === undefined || t.id === toastId ? { ...t, open: false } : t,\n ),\n };\n }\n case \"REMOVE_TOAST\":\n if (action.toastId === undefined) return { toasts: [] };\n return { toasts: state.toasts.filter((t) => t.id !== action.toastId) };\n }\n}\n\nfunction dispatch(action: Action): void {\n memoryState = reducer(memoryState, action);\n for (const listener of listeners) listener(memoryState);\n}\n\nexport type ToastOptions = Omit<ToasterToast, \"id\">;\n\n/**\n * Imperatively enqueue a toast. Returns handles to update or dismiss it.\n * Requires a single `<Toaster />` mounted in the tree.\n */\nexport function toast(options: ToastOptions) {\n const id = genId();\n const dismiss = () => dispatch({ type: \"DISMISS_TOAST\", toastId: id });\n const update = (next: Partial<ToasterToast>) =>\n dispatch({ type: \"UPDATE_TOAST\", toast: { ...next, id } });\n\n dispatch({\n type: \"ADD_TOAST\",\n toast: {\n ...options,\n id,\n open: true,\n onOpenChange: (open: boolean) => {\n if (!open) dismiss();\n },\n },\n });\n\n return { id, dismiss, update };\n}\n\n/**\n * Subscribe to the toast store. Use inside `Toaster`, or anywhere you need to\n * enqueue toasts (`const { toast } = useToast()`).\n */\nexport function useToast() {\n const [state, setState] = React.useState<State>(memoryState);\n\n React.useEffect(() => {\n listeners.add(setState);\n setState(memoryState);\n return () => {\n listeners.delete(setState);\n };\n }, []);\n\n return {\n ...state,\n toast,\n dismiss: (toastId?: string) => dispatch({ type: \"DISMISS_TOAST\", toastId }),\n };\n}\n\n/* -------------------------------------------------------------------------- */\n/* Toaster — mount once; renders the live region and queued toasts */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Toaster — the toast host. Mount exactly one, typically in a client providers\n * component near the root. Radix Toast supplies the `role`/`aria-live` region,\n * hover-to-pause, swipe-to-dismiss, and F8-to-focus behaviors.\n */\nexport function Toaster() {\n const { toasts } = useToast();\n\n return (\n <ToastProvider>\n {toasts.map(({ id, title, description, action, ...props }) => (\n <Toast key={id} {...props}>\n <div className=\"grid gap-1\">\n {title ? <ToastTitle>{title}</ToastTitle> : null}\n {description ? (\n <ToastDescription>{description}</ToastDescription>\n ) : null}\n </div>\n {action}\n <ToastClose />\n </Toast>\n ))}\n <ToastViewport />\n </ToastProvider>\n );\n}\n"
}
],
"meta": {
"frameworks": [
"react",
"next"
],
"rsc": "client",
"exports": [
"Toaster",
"useToast",
"toast",
"Toast",
"ToastProvider",
"ToastViewport",
"ToastTitle",
"ToastDescription",
"ToastAction",
"ToastClose"
],
"variants": {
"variant": [
"default",
"success",
"danger"
]
},
"cssVars": [
"--vexa-surface-raised",
"--vexa-border",
"--vexa-foreground",
"--vexa-muted-foreground",
"--vexa-success",
"--vexa-danger",
"--vexa-accent",
"--vexa-ring",
"--vexa-background"
],
"a11y": "Radix Toast provides the live region (role + aria-live), hover-to-pause, swipe-to-dismiss, and F8-to-focus. Status is carried by text; mount exactly one <Toaster />. Enter/exit animation respects reduced motion.",
"example": "const { dismiss } = toast({ title: \"Saved\", description: \"Your changes are live.\" })",
"tests": "planned — see docs/README.md#roadmap"
}
}Detalles
Variantes
- variant
- defaultsuccessdanger
Accesibilidad
Radix Toast provides the live region (role + aria-live), hover-to-pause, swipe-to-dismiss, and F8-to-focus. Status is carried by text; mount exactly one <Toaster />. Enter/exit animation respects reduced motion.
Uso
A transient, non-blocking notification of an event result. Call `toast(...)` imperatively.
Anatomía
- Toaster
- Mount exactly once — provider + viewport.
- toast() / useToast
- Imperative API to enqueue a toast.
- Toast
- A single notification surface.
- ToastTitle / ToastDescription
- Heading and body text.
- ToastAction
- An optional action button.
- ToastClose
- Dismiss control (a non-drag alternative).
Accesibilidad
Radix supplies the live region (role + `aria-live`); status is carried by the text.
Teclado
| Tecla | Acción |
|---|---|
| F8 | Move focus to the toast region |
| Enter / Space | Activate a focused ToastAction / ToastClose |
Foco: Non-focus-stealing; hover-to-pause. ToastClose gives a keyboard/click dismissal alongside swipe.
- Provide ToastClose as a non-drag dismissal (WCAG 2.2 §2.5.7). Mount exactly one Toaster; enter/exit animation respects reduced motion.
Buenas prácticas
Recomendado
- Mount exactly one Toaster.
- Convey status through text.
- Provide a ToastClose and keep the message brief.
Evitar
- Don't put an essential-only action in an auto-dismissing toast.
- Don't require a swipe to dismiss — offer Close.
- Don't stack many toasts at once.
¿Te ha resultado útil esta página?
Guardado localmente en este navegador. Sin cuenta ni servidor.