shadimage
Goose UI
uicustom-toastsource: ok · hit

Custom Toast

Beautiful toast with progress bar and colored variants.

npx shadcn add "https://gooseui.pro/r/custom-toast.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
toastssonner
iconslucide-react

Registry dependencies

button

Files1 · source cached 10/10/2026, 9:27:17 PM

registry/new-york/ui/custom-toast.tsx
"use client"

import {
  CircleCheckIcon,
  InfoIcon,
  OctagonXIcon,
  TriangleAlertIcon,
  XIcon,
} from "lucide-react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

type ToastType = "success" | "error" | "warning" | "info"

interface CustomToastProps {
  id: string | number
  type: ToastType
  message: string
  description?: string
  duration?: number
}

const config: Record<
  ToastType,
  { icon: React.ElementType; progressColor: string; iconColor: string }
> = {
  success: {
    icon: CircleCheckIcon,
    progressColor: "bg-green-500",
    iconColor: "text-green-500",
  },
  error: {
    icon: OctagonXIcon,
    progressColor: "bg-red-500",
    iconColor: "text-red-500",
  },
  warning: {
    icon: TriangleAlertIcon,
    progressColor: "bg-amber-500",
    iconColor: "text-amber-500",
  },
  info: {
    icon: InfoIcon,
    progressColor: "bg-blue-500",
    iconColor: "text-blue-500",
  },
}

export function CustomToast({
  id,
  type,
  message,
  description,
  duration = 4000,
}: CustomToastProps) {
  const [progress, setProgress] = useState(100)
  const { icon: Icon, progressColor, iconColor } = config[type]

  useEffect(() => {
    const startTime = Date.now()

    const interval = setInterval(() => {
      const elapsed = Date.now() - startTime
      const remaining = Math.max(0, 100 - (elapsed / duration) * 100)
      setProgress(remaining)

      if (remaining === 0) {
        clearInterval(interval)
        toast.dismiss(id)
      }
    }, 16)

    return () => clearInterval(interval)
  }, [duration, id])

  return (
    <div
      className={cn(
        "relative flex w-full items-start gap-3 overflow-hidden rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg",
        "border-l-4",
        type === "success" && "border-l-green-500",
        type === "error" && "border-l-red-500",
        type === "warning" && "border-l-amber-500",
        type === "info" && "border-l-blue-500",
      )}
    >
      <Icon className={cn("mt-0.5 size-5 shrink-0", iconColor)} />

      <div className="flex-1 space-y-1">
        <p className="text-base font-medium leading-tight">{message}</p>
        {description && (
          <p className="text-sm text-muted-foreground whitespace-pre-line">
            {description}
          </p>
        )}
      </div>

      <Button
        variant="ghost"
        size="icon"
        onClick={() => toast.dismiss(id)}
        className="size-6 shrink-0 opacity-70 hover:opacity-100"
      >
        <XIcon className="size-4" />
      </Button>

      {/* Progress bar */}
      <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-muted/50">
        <div
          className={cn(
            "h-full transition-all duration-[16ms] ease-linear",
            progressColor,
          )}
          style={{ width: `${progress}%` }}
        />
      </div>
    </div>
  )
}

More from Goose UI

Browse all components in this library →