overlaysclient
toast
little notes that stack up in the corner and fan out when you hover. wrap your app in ToastProvider, then call toast() from anywhere.
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add toastor by hand: drop these files into components/ui and import from there. toast leans on CloseButton.tsx, so those come along too.
components/ui/Toast.tsxtsx
1"use client";2
3import React, {4 useState,5 useEffect,6 useCallback,7 createContext,8 useContext,9 useRef,10 useLayoutEffect,11} from "react";12import { CloseButton } from "./CloseButton";13
14type ToastVariant = "default" | "success" | "warning" | "error";15
16interface ToastItem {17 id: string;18 title: string;19 description?: string;20 variant: ToastVariant;21 duration: number;22}23
24interface ToastContextValue {25 toast: (opts: {26 title: string;27 description?: string;28 variant?: ToastVariant;29 duration?: number;30 }) => void;31}32
33const ToastContext = createContext<ToastContextValue>({ toast: () => {} });34
35export function useToast() {36 return useContext(ToastContext);37}38
39const variantConfig: Record<40 ToastVariant,41 { tile: string; icon: React.ReactNode }42> = {43 default: {44 tile: "var(--face-dark)",45 icon: (46 <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">47 <circle cx="12" cy="12" r="10" />48 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />49 </svg>50 ),51 },52 success: {53 tile: "linear-gradient(180deg, #10b981 0%, #059669 100%)",54 icon: (55 <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">56 <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />57 </svg>58 ),59 },60 warning: {61 tile: "linear-gradient(180deg, #fbbf24 0%, #f59e0b 100%)",62 icon: (63 <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">64 <path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />65 </svg>66 ),67 },68 error: {69 tile: "linear-gradient(180deg, #f87171 0%, #dc2626 100%)",70 icon: (71 <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.75" viewBox="0 0 24 24">72 <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />73 </svg>74 ),75 },76};77
78const TOAST_WIDTH = "min(360px, calc(100vw - 24px))";79const VISIBLE_STACK = 3;80const STACK_OFFSET = 10;81const STACK_SCALE_STEP = 0.05;82const GAP = 10;83const ENTER_MS = 320;84const EXIT_MS = 220;85const SHIFT_MS = 420;86
87interface ToastCardProps {88 item: ToastItem;89 index: number;90 total: number;91 expanded: boolean;92 expandedOffset: number;93 exiting: boolean;94 entering: boolean;95 paused: boolean;96 onDismiss: (id: string) => void;97 onMeasure: (id: string, height: number) => void;98}99
100function ToastCard({101 item,102 index,103 total,104 expanded,105 expandedOffset,106 exiting,107 entering,108 paused,109 onDismiss,110 onMeasure,111}: ToastCardProps) {112 const ref = useRef<HTMLDivElement>(null);113 const innerRef = useRef<HTMLDivElement>(null);114 const remainingRef = useRef(item.duration);115 const startRef = useRef<number | null>(null);116 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);117
118 useLayoutEffect(() => {119 if (!innerRef.current) return;120 const ro = new ResizeObserver(() => {121 if (innerRef.current) onMeasure(item.id, innerRef.current.offsetHeight);122 });123 ro.observe(innerRef.current);124 onMeasure(item.id, innerRef.current.offsetHeight);125 return () => ro.disconnect();126 }, [item.id, onMeasure]);127
128 useEffect(() => {129 if (exiting) {130 if (timerRef.current) clearTimeout(timerRef.current);131 return;132 }133 if (paused) {134 if (timerRef.current) {135 clearTimeout(timerRef.current);136 timerRef.current = null;137 if (startRef.current !== null) {138 remainingRef.current = Math.max(139 0,140 remainingRef.current - (Date.now() - startRef.current),141 );142 }143 }144 return;145 }146 startRef.current = Date.now();147 timerRef.current = setTimeout(() => {148 onDismiss(item.id);149 }, remainingRef.current);150 return () => {151 if (timerRef.current) clearTimeout(timerRef.current);152 };153 }, [paused, exiting, item.id, onDismiss]);154
155 const { tile, icon } = variantConfig[item.variant];156
157 const hidden = !expanded && index >= VISIBLE_STACK;158
159 const collapsedTransform = `translateX(-50%) translateY(${160 index * STACK_OFFSET161 }px) scale(${1 - index * STACK_SCALE_STEP})`;162
163 const expandedTransform = `translateX(-50%) translateY(${expandedOffset}px) scale(1)`;164
165 let transform = expanded ? expandedTransform : collapsedTransform;166 let opacity = hidden ? 0 : 1;167
168 if (entering) {169 transform = `translateX(-50%) translateY(-14px) scale(0.96)`;170 opacity = 0;171 }172 if (exiting) {173 transform = expanded174 ? `translateX(-50%) translateY(${expandedOffset - 12}px) scale(0.96)`175 : `translateX(-50%) translateY(${176 index * STACK_OFFSET - 12177 }px) scale(${1 - index * STACK_SCALE_STEP - 0.02})`;178 opacity = 0;179 }180
181 return (182 <div183 ref={ref}184 className="absolute top-0 left-1/2 pointer-events-auto"185 style={{186 width: TOAST_WIDTH,187 transform,188 opacity,189 zIndex: total - index,190 transformOrigin: "top center",191 transition: `transform ${SHIFT_MS}ms cubic-bezier(0.32,0.72,0,1), opacity ${192 exiting ? EXIT_MS : ENTER_MS193 }ms ease`,194 }}195 >196 <div197 ref={innerRef}198 className="rounded-[18px] p-1.5"199 style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)" }}200 >201 <div202 className="bg-white rounded-[13px] pl-3 pr-2 py-3 flex items-start gap-3"203 style={{ boxShadow: "var(--panel-face-shadow)" }}204 >205 <div206 aria-hidden207 className="shrink-0 w-7 h-7 rounded-[8px] flex items-center justify-center text-white"208 style={{209 background: tile,210 boxShadow:211 "0 0 3px rgba(0,0,0,0.212), 0 1px 2px rgba(0,0,0,0.15), inset 0 2px 3px -2px rgba(255,255,255,0.188)",212 }}213 >214 {icon}215 </div>216 <div className="flex-1 min-w-0 pt-[5px]">217 <p className="text-[13px] font-semibold text-neutral-900 leading-tight tracking-[-0.01em]">218 {item.title}219 </p>220 {item.description && (221 <p className="text-[12.5px] text-neutral-500 mt-1 leading-relaxed">222 {item.description}223 </p>224 )}225 </div>226 <CloseButton size="sm" label="Dismiss notification" onClick={() => onDismiss(item.id)} />227 </div>228 </div>229 </div>230 );231}232
233export function ToastProvider({ children }: { children: React.ReactNode }) {234 const [toasts, setToasts] = useState<ToastItem[]>([]);235 const [expanded, setExpanded] = useState(false);236 const [heights, setHeights] = useState<Record<string, number>>({});237 const [exiting, setExiting] = useState<Set<string>>(new Set());238 const [entering, setEntering] = useState<Set<string>>(new Set());239 const counter = useRef(0);240
241 const reportHeight = useCallback((id: string, h: number) => {242 setHeights((prev) => (prev[id] === h ? prev : { ...prev, [id]: h }));243 }, []);244
245 const dismiss = useCallback((id: string) => {246 setExiting((prev) => {247 if (prev.has(id)) return prev;248 const next = new Set(prev);249 next.add(id);250 return next;251 });252 setTimeout(() => {253 setToasts((prev) => prev.filter((t) => t.id !== id));254 setExiting((prev) => {255 const next = new Set(prev);256 next.delete(id);257 return next;258 });259 setHeights((prev) => {260 const next = { ...prev };261 delete next[id];262 return next;263 });264 }, EXIT_MS);265 }, []);266
267 const toast = useCallback(268 (opts: {269 title: string;270 description?: string;271 variant?: ToastVariant;272 duration?: number;273 }) => {274 const id = `toast-${++counter.current}`;275 const item: ToastItem = {276 id,277 title: opts.title,278 description: opts.description,279 variant: opts.variant ?? "default",280 duration: opts.duration ?? 4000,281 };282 setToasts((prev) => [...prev, item]);283 setEntering((prev) => {284 const next = new Set(prev);285 next.add(id);286 return next;287 });288 requestAnimationFrame(() => {289 requestAnimationFrame(() => {290 setEntering((prev) => {291 const next = new Set(prev);292 next.delete(id);293 return next;294 });295 });296 });297 },298 [],299 );300
301 const list = [...toasts].reverse();302
303 const expandedOffsets: number[] = [];304 let cum = 0;305 for (let i = 0; i < list.length; i++) {306 expandedOffsets.push(cum);307 cum += (heights[list[i].id] ?? 72) + GAP;308 }309 const expandedHeight = Math.max(0, cum - GAP);310
311 const topH = heights[list[0]?.id] ?? 72;312 const visibleCount = Math.min(list.length, VISIBLE_STACK);313 const collapsedHeight =314 list.length === 0315 ? 0316 : topH + Math.max(0, visibleCount - 1) * STACK_OFFSET;317
318 const containerHeight = expanded ? expandedHeight : collapsedHeight;319
320 const showCount = list.length - VISIBLE_STACK;321
322 return (323 <ToastContext.Provider value={{ toast }}>324 {children}325 {/* Hover or keyboard focus fans the stack out and pauses the timers */}326 <section327 aria-label="Notifications"328 aria-live="polite"329 className="fixed top-[72px] left-1/2 z-[95] pointer-events-none"330 style={{331 width: TOAST_WIDTH,332 transform: "translateX(-50%)",333 }}334 onMouseEnter={() => setExpanded(true)}335 onMouseLeave={() => setExpanded(false)}336 onFocus={() => setExpanded(true)}337 onBlur={(e) => {338 if (!e.currentTarget.contains(e.relatedTarget as Node)) setExpanded(false);339 }}340 >341 <div342 className={list.length > 0 ? "relative pointer-events-auto" : "relative"}343 style={{344 width: TOAST_WIDTH,345 height: containerHeight,346 transition: `height ${SHIFT_MS}ms cubic-bezier(0.32,0.72,0,1)`,347 }}348 >349 {list.map((t, i) => (350 <ToastCard351 key={t.id}352 item={t}353 index={i}354 total={list.length}355 expanded={expanded}356 expandedOffset={expandedOffsets[i]}357 exiting={exiting.has(t.id)}358 entering={entering.has(t.id)}359 paused={expanded}360 onDismiss={dismiss}361 onMeasure={reportHeight}362 />363 ))}364
365 {!expanded && showCount > 0 && (366 <div367 className="absolute left-1/2 -translate-x-1/2 pointer-events-none"368 style={{369 top: collapsedHeight + 6,370 transition: `opacity ${ENTER_MS}ms ease`,371 }}372 >373 <span374 className="text-[10.5px] font-medium text-neutral-500 px-2 py-0.5 rounded-full"375 style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}376 >377 +{showCount} more378 </span>379 </div>380 )}381 </div>382 </section>383 </ToastContext.Provider>384 );385}components/ui/CloseButton.tsxtsx
1"use client";2
3import React from "react";4
5interface CloseButtonProps {6 onClick?: () => void;7 label?: string;8 size?: "sm" | "md";9 tone?: "light" | "dark";10 className?: string;11}12
13const sizes = {14 sm: { box: "w-6 h-6 rounded-[7px]", icon: 9 },15 md: { box: "w-7 h-7 rounded-[8px]", icon: 10 },16};17
18/* Quiet until hovered, then it rises into a raised face like every other19 pressable thing — so it reads as a control, not decoration. */20export function CloseButton({21 onClick,22 label = "Close",23 size = "md",24 tone = "light",25 className = "",26}: CloseButtonProps) {27 const dark = tone === "dark";28 const s = sizes[size];29
30 return (31 <button32 type="button"33 onClick={onClick}34 aria-label={label}35 className={[36 "group/close relative shrink-0 flex items-center justify-center cursor-pointer",37 "transition-[color,background,scale] duration-150 active:scale-95",38 "focus-visible:outline-2 focus-visible:outline-offset-2",39 dark40 ? "text-white/45 hover:text-white focus-visible:outline-white"41 : "text-neutral-400 hover:text-neutral-900 focus-visible:outline-neutral-900",42 s.box,43 className,44 ].join(" ")}45 >46 <span47 aria-hidden48 className={[49 "absolute inset-0 rounded-[inherit] opacity-0 group-hover/close:opacity-100 transition-opacity duration-150",50 dark51 ? "[background:var(--tile-dark)] [box-shadow:var(--tile-dark-shadow)]"52 : "[background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",53 ].join(" ")}54 />55 <svg56 className="relative"57 width={s.icon}58 height={s.icon}59 fill="none"60 stroke="currentColor"61 strokeWidth="2.5"62 viewBox="0 0 24 24"63 >64 <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />65 </svg>66 </button>67 );68}usage
tsx
import { useToast, ToastProvider } from "@blurrow/ui";tsx
// app/layout.tsx<ToastProvider>{children}</ToastProvider>
// anywhere below itconst { toast } = useToast();toast({ title: "post saved", variant: "success" });examples
variants
props
straight from the source, so they're always up to date.
propsts
type ToastVariant = "default" | "success" | "warning" | "error";
interface ToastContextValue { toast: (opts: { title: string; description?: string; variant?: ToastVariant; duration?: number; }) => void;}