Toast (Tailwind)

PreviousNext

A succinct message notification.

Docs
roiuiitem

Preview

Loading preview…
registry/brook/tailwind/ui/toast.tsx
"use client";

import { Toast } from "@base-ui/react/toast";
import { X } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";

const toastManager = Toast.createToastManager();
const anchoredToastManager = Toast.createToastManager();

function ToastProvider({ children, ...props }: Toast.Provider.Props) {
  return (
    <Toast.Provider toastManager={toastManager} {...props}>
      {children}
      <Toast.Portal>
        <ToastViewport>
          <ToastList />
        </ToastViewport>
      </Toast.Portal>
    </Toast.Provider>
  );
}

function ToastViewport({ className, ...props }: Toast.Viewport.Props) {
  return (
    <Toast.Viewport
      className={cn(
        "fixed top-auto right-4 bottom-4 left-auto z-[1000] m-auto w-[250px]",
        "sm:right-8 sm:bottom-8 sm:w-[340px]",
        className
      )}
      data-slot="toast-viewport"
      {...props}
    />
  );
}

function ToastRoot({ className, ...props }: Toast.Root.Props) {
  return (
    <Toast.Root
      className={cn(
        "[--gap:0.75rem]",
        "[--peek:0.75rem]",
        "[--scale:calc(max(0,1-(var(--toast-index)*0.1)))]",
        "[--shrink:calc(1-var(--scale))]",
        "[--height:var(--toast-frontmost-height,var(--toast-height))]",
        "[--offset-y:calc(var(--toast-offset-y)*-1+calc(var(--toast-index)*var(--gap)*-1)+var(--toast-swipe-movement-y))]",
        "absolute right-0 bottom-0 left-auto z-[calc(1000-var(--toast-index))] mr-0 w-full origin-bottom",
        "select-none rounded-[0.5rem] bg-[var(--mix-card-5-bg)] bg-clip-padding p-4",
        "shadow-[0_0_0_0.5px_oklch(from_var(--border)_l_c_h_/_0.5),oklch(from_var(--border)_l_c_h_/_0.2)_0px_1px_1px,oklch(from_var(--border)_l_c_h_/_0.2)_0px_1px_1px,oklch(from_var(--border)_l_c_h_/_0.2)_0px_1px_1px]",
        "[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)-(var(--toast-index)*var(--peek))-(var(--shrink)*var(--height))))_scale(var(--scale))]",
        "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']",
        "data-[ending-style]:opacity-0",
        "data-[expanded]:[transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--offset-y)))]",
        "data-[limited]:opacity-0",
        "data-[starting-style]:[transform:translateY(150%)]",
        "data-[starting-style]:opacity-0",
        "[&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(150%)]",
        "data-[ending-style]:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
        "data-[expanded]:data-[ending-style]:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))]",
        "data-[ending-style]:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
        "data-[expanded]:data-[ending-style]:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))]",
        "data-[ending-style]:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
        "data-[expanded]:data-[ending-style]:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]",
        "data-[ending-style]:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
        "data-[expanded]:data-[ending-style]:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]",
        "h-[var(--height)]",
        "data-[expanded]:h-[var(--toast-height)]",
        "[transition:transform_0.35s_ease,opacity_0.5s,height_0.3s_var(--ease-out-expo)]",
        className
      )}
      data-slot="toast-root"
      {...props}
    />
  );
}

function ToastContent({ className, ...props }: Toast.Content.Props) {
  return (
    <Toast.Content
      className={cn(
        "overflow-hidden transition-opacity [transition-duration:500ms]",
        "data-[behind]:pointer-events-none data-[behind]:opacity-0",
        "data-[expanded]:pointer-events-auto data-[expanded]:opacity-100",
        className
      )}
      data-slot="toast-content"
      {...props}
    />
  );
}

function ToastTitle({ className, ...props }: Toast.Title.Props) {
  return (
    <Toast.Title
      className={cn("m-0 font-[450] text-[0.975rem] text-[var(--foreground)] leading-5 tracking-[-0.02em]", className)}
      data-slot="toast-title"
      {...props}
    />
  );
}

function ToastDescription({ className, ...props }: Toast.Description.Props) {
  return (
    <Toast.Description
      className={cn(
        "m-0 mt-1 text-[0.875rem] text-[var(--secondary-foreground)] leading-5 tracking-[-0.02em]",
        className
      )}
      data-slot="toast-description"
      {...props}
    />
  );
}

function ToastAction({ className, ...props }: Toast.Action.Props) {
  return (
    <Toast.Action
      className={cn(
        "inline-flex h-8 items-center justify-center rounded px-3",
        "font-medium text-sm leading-5",
        "mt-2 cursor-pointer border-none",
        "bg-[var(--primary)] text-[var(--primary-foreground)]",
        "transition-all duration-150",
        "hover:opacity-90",
        "focus:outline-none",
        "focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--ring)] focus-visible:outline-offset-2",
        className
      )}
      data-slot="toast-action"
      {...props}
    />
  );
}

function ToastClose({ className, children, ...props }: Toast.Close.Props) {
  return (
    <Toast.Close
      className={cn(
        "absolute top-2 right-2 flex h-5 w-5 items-center justify-center rounded-sm border-none bg-transparent",
        "text-[oklch(from_var(--foreground)_l_c_h_/_0.3)] transition-all duration-150",
        "hover:bg-[var(--muted)] hover:text-[var(--foreground)]",
        "focus:outline-none",
        "focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--ring)] focus-visible:outline-offset-2",
        className
      )}
      data-slot="toast-close"
      {...props}
    >
      {children}
    </Toast.Close>
  );
}

function ToastList() {
  const { toasts } = Toast.useToastManager();
  return toasts.map((toast) => (
    <ToastRoot key={toast.id} toast={toast}>
      <ToastContent>
        <div className="flex items-start justify-between gap-2">
          <div className="min-w-0 flex-1">
            <div className="flex items-center gap-2">
              <ToastTitle />
              {toast.type && (
                <span
                  className={cn(
                    "inline-block h-[5px] w-[10px] rounded-[var(--radius)]",
                    toast.type === "success" && "bg-[var(--success)]",
                    toast.type === "error" && "bg-[var(--destructive)]",
                    toast.type === "warning" && "bg-[var(--warning)]",
                    toast.type === "info" && "bg-[var(--info)]"
                  )}
                />
              )}
            </div>
            <ToastDescription />
            {toast.actionProps && <ToastAction className="mt-2">{toast.actionProps.children}</ToastAction>}
          </div>
        </div>
        {toast.data?.showCloseButton === true && (
          <ToastClose>
            <X className="h-4 w-4" />
          </ToastClose>
        )}
      </ToastContent>
    </ToastRoot>
  ));
}

function AnchoredToasts() {
  const { toasts } = Toast.useToastManager();

  return (
    <Toast.Portal>
      <Toast.Viewport className="outline-0">
        {toasts.map((toast) => {
          const positionerProps = toast.positionerProps;

          if (!positionerProps?.anchor) {
            return null;
          }

          return (
            <Toast.Positioner
              className="z-50 max-w-[min(16rem,var(--available-width))]"
              key={toast.id}
              sideOffset={positionerProps.sideOffset ?? 4}
              toast={toast}
            >
              <Toast.Root
                className={cn(
                  "relative text-balance",
                  "bg-[var(--popover)] bg-clip-padding text-[var(--popover-foreground)]",
                  "rounded-[calc(var(--radius)-2px)] text-xs",
                  "shadow-[0_0_0_0.5px_var(--border),0_4px_6px_-1px_oklch(from_var(--foreground)_l_c_h_/_0.05)]",
                  "transition-[scale,opacity] duration-150",
                  "before:pointer-events-none before:absolute before:inset-0",
                  "before:rounded-[calc(var(--radius)-3px)]",
                  "before:shadow-[inset_0_1px_0_oklch(from_var(--background)_l_c_h_/_0.04)]",
                  "data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0",
                  "data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0"
                )}
                toast={toast}
              >
                <Toast.Content className="pointer-events-auto px-2 py-1">
                  <Toast.Title className="m-0 whitespace-nowrap font-normal" />
                </Toast.Content>
              </Toast.Root>
            </Toast.Positioner>
          );
        })}
      </Toast.Viewport>
    </Toast.Portal>
  );
}

function AnchoredToastProvider({ children }: { children: React.ReactNode }) {
  return (
    <Toast.Provider toastManager={anchoredToastManager}>
      {children}
      <AnchoredToasts />
    </Toast.Provider>
  );
}

export { ToastProvider, toastManager, AnchoredToastProvider, anchoredToastManager };

Installation

npx shadcn@latest add @roiui/toast-tailwind

Usage

import { ToastTailwind } from "@/components/toast-tailwind"
<ToastTailwind />