Saltar al contenido
VEXA
Feedback

Toast

Hoja cliente

A 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.

Escritorio

Playground

Ajusta las props y copia el JSX equivalente.

Playground

toast.tsx
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.json

Se 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.

components/vexa/toast.tsx
"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
r/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

TeclaAcción
F8Move focus to the toast region
Enter / SpaceActivate 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.
Editar esta página

¿Te ha resultado útil esta página?

Guardado localmente en este navegador. Sin cuenta ni servidor.