Toast
จาก shadcnการแจ้งผลลัพธ์ชั่วคราวที่ลอยขึ้นจากขอบล่างจอ มีไอคอนและสีตามชนิด (สำเร็จ แจ้งเตือน ผิดพลาด กำลังโหลด) เลื่อนดูหลายใบแล้วปัดปิดได้
ติดตั้ง
Terminal
pnpm dlx shadcn@latest add @whereui/toastต้องผูก registry และติดตั้ง @whereui/theme นำหน้าเสมอเมื่อติดตั้งครั้งแรก
การใช้งาน
import { toast, Toaster } from "@/components/ui/toast"import { Button } from "@/components/ui/button"<Toaster /><Button onClick={() => toast.add({ type: "success", title: "บันทึกแล้ว" })}> บันทึก</Button>ข้อควรรู้
- เรียก toast.add({ type, title, description }) จากที่ใดก็ได้ โดยมี type เป็น success, info, warning, error หรือ loading
- ต้องมี Toaster ติดตั้งไว้ในหน้า (ครั้งเดียว) ไม่งั้น toast จะไม่แสดง
- toast ปรากฏที่มุมล่างขวาบนเดสก์ท็อป และกว้างเต็มขอบจอบนมือถือ
ซอร์สโค้ด
components/ui/toast.tsx
"use client"import * as React from "react"import { Toast as ToastPrimitive } from "@base-ui/react/toast"import { cn } from "cn"import { Button } from "@/components/ui/button"import { XIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"const toast = ToastPrimitive.createToastManager()function ToastProvider({ ...props }: ToastPrimitive.Provider.Props) { return <ToastPrimitive.Provider {...props} />}function ToastPortal({ ...props }: ToastPrimitive.Portal.Props) { return <ToastPrimitive.Portal data-slot="toast-portal" {...props} />}function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) { return ( <ToastPrimitive.Viewport data-slot="toast-viewport" className={cn( "pointer-events-none fixed inset-x-4 bottom-4 z-50 mx-auto w-auto max-w-sm outline-none sm:right-4 sm:left-auto sm:mx-0 sm:w-full", className )} {...props} /> )}function Toast({ className, ...props }: ToastPrimitive.Root.Props) { return ( <ToastPrimitive.Root data-slot="toast" className={cn( "group/toast pointer-events-auto absolute right-0 bottom-0 z-[calc(1000-var(--toast-index))] w-full origin-bottom rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "[--gap:0.75rem] [--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))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))]", "h-(--height) [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))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms]", "after:absolute after:top-full after:left-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-['']", "data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))]", "data-limited:opacity-0 data-starting-style:[transform:translateY(150%)]", "[&[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-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-ending-style:data-[swipe-direction=up]:[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-expanded: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=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))]", "data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))]", className )} {...props} /> )}function ToastContent({ className, ...props }: ToastPrimitive.Content.Props) { return ( <ToastPrimitive.Content data-slot="toast-content" className={cn( "flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100", className )} {...props} /> )}function ToastTitle({ className, ...props }: ToastPrimitive.Title.Props) { return ( <ToastPrimitive.Title data-slot="toast-title" className={cn("text-sm font-medium", className)} {...props} /> )}function ToastDescription({ className, ...props}: ToastPrimitive.Description.Props) { return ( <ToastPrimitive.Description data-slot="toast-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> )}function ToastAction({ className, render = <Button variant="outline" size="sm" />, ...props}: ToastPrimitive.Action.Props) { return ( <ToastPrimitive.Action data-slot="toast-action" render={render} className={cn("shrink-0", className)} {...props} /> )}function ToastClose({ className, children, render = <Button variant="ghost" size="icon-sm" />, ...props}: ToastPrimitive.Close.Props) { return ( <ToastPrimitive.Close data-slot="toast-close" aria-label="Close toast" render={render} className={cn( "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground", className )} {...props} > {children ?? ( <XIcon aria-hidden="true" /> )} </ToastPrimitive.Close> )}function ToastIcon({ type }: { type: string | undefined }) { let icon: React.ReactNode = null if (type === "success") { icon = ( <CircleCheckIcon aria-hidden="true" /> ) } if (type === "info") { icon = ( <InfoIcon aria-hidden="true" /> ) } if (type === "warning") { icon = ( <TriangleAlertIcon aria-hidden="true" /> ) } if (type === "error") { icon = ( <OctagonXIcon className="text-destructive" aria-hidden="true" /> ) } if (type === "loading") { icon = ( <Loader2Icon className="animate-spin" aria-hidden="true" /> ) } if (!icon) { return null } return ( <span data-slot="toast-icon" className="shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4" > {icon} </span> )}function ToastList() { const { toasts } = ToastPrimitive.useToastManager() return toasts.map((toastItem) => ( <Toast key={toastItem.id} toast={toastItem}> <ToastContent> <ToastIcon type={toastItem.type} /> <div className="flex min-w-0 flex-1 flex-col gap-1"> <ToastTitle /> <ToastDescription /> </div> <ToastAction /> <ToastClose /> </ToastContent> </Toast> ))}function Toaster({ children, toastManager = toast, ...props}: ToastPrimitive.Provider.Props) { return ( <ToastProvider toastManager={toastManager} {...props}> {children} <ToastPortal> <ToastViewport> <ToastList /> </ToastViewport> </ToastPortal> </ToastProvider> )}const createToastManager = ToastPrimitive.createToastManagerconst useToastManager = ToastPrimitive.useToastManagerexport { Toaster, Toast, ToastAction, ToastClose, ToastContent, ToastDescription, ToastPortal, ToastProvider, ToastTitle, ToastViewport, createToastManager, toast, useToastManager,}