Skip to content
Motif 母题

Gooey Toast

A pill drops in from the top, then stretches like a drop of water into a body with a description and an action, and folds back before leaving. The outline is a morphing SVG path on an overshooting spring. For success, failure and update notices that need one line and one action.

Loading preview

Tune

Presets

Layout
320 px
Look
Motion
0.40
0.5 s
Interaction
2.8 s
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 anl331
// Source: https://github.com/anl331/goey-toast/blob/be6bd88/src/components/GooeyToast.tsx
// Modified by Motif; see the item's provenance.
import { animate, AnimatePresence, motion, useMotionValue, useMotionValueEvent, useReducedMotion } from 'motion/react'
import { cn } from '@motif/runtime'
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
/* @motif:defaults */
export const defaults = {
align: 'center',
width: 320,
fill: '#18181b',
bounce: 0.4,
expandDelay: 0.5,
hold: 2.8,
richColors: false,
border: true,
}
/* @motif:end */
export type GooeyToastType = 'default' | 'success' | 'error' | 'warning' | 'info' | 'loading'
export type GooeyToastData = {
id: string
title: string
description?: string
type?: GooeyToastType
action?: { label: string; onClick?: () => void }
}
export type ToastGooeyProps = Partial<typeof defaults> & {
className?: string
style?: CSSProperties
/** 当前要显示的 toast;传 null 时收起并退场。换 id 会重新开始一轮「弹出、展开、收起」。 */
toast?: GooeyToastData | null
/** 一轮结束(收起完毕)时调用,由调用方清掉 toast。 */
onDismiss?: () => void
}
/** 胶囊高度;与 goey-toast 一样,胶囊保持不变,下方的主体从它下面长出来。 */
const PH = 40
const COLLAPSE_SECONDS = 0.9
const ACCENT: Record<string, string> = {
success: '#22c55e',
error: '#ef4444',
warning: '#f59e0b',
info: '#3b82f6',
loading: '#a1a1aa',
}
function isDark(hex: string): boolean {
const value = hex.replace('#', '')
const r = parseInt(value.slice(0, 2), 16)
const g = parseInt(value.slice(2, 4), 16)
const b = parseInt(value.slice(4, 6), 16)
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.6
}
/**
* 形变路径:t=0 是一颗纯胶囊;t=1 时胶囊居中停在顶部,主体从它下面向两侧长出,
* 胶囊两侧与主体之间用二次曲线连成一个「颈」,像一滴水被拉开。
* 几何来自 goey-toast 的 morphPathCenter;align 为 left/right 时胶囊靠一侧,右侧用镜像得到。
*/
function morphPath(pw: number, bw: number, th: number, t: number, centered: boolean): string {
const pr = PH / 2
const pillW = Math.min(pw, bw)
const pillOffset = centered ? (bw - pillW) / 2 : 0
const bodyH = PH + (th - PH) * t
if (t <= 0.001 || bodyH - PH < 8) {
return [
`M ${pillOffset},${pr}`,
`A ${pr},${pr} 0 0 1 ${pillOffset + pr},0`,
`H ${pillOffset + pillW - pr}`,
`A ${pr},${pr} 0 0 1 ${pillOffset + pillW},${pr}`,
`A ${pr},${pr} 0 0 1 ${pillOffset + pillW - pr},${PH}`,
`H ${pillOffset + pr}`,
`A ${pr},${pr} 0 0 1 ${pillOffset},${pr}`,
'Z',
].join(' ')
}
const curve = 14 * Math.min(t, 1.15)
const cr = Math.max(4, Math.min(18, (bodyH - PH) * 0.45))
const bodyTop = PH - curve
if (!centered) {
const bodyW = pillW + (bw - pillW) * Math.min(t, 1.1)
const qEndX = Math.min(pillW + curve, bodyW - cr)
return [
`M 0,${pr}`,
`A ${pr},${pr} 0 0 1 ${pr},0`,
`H ${pillW - pr}`,
`A ${pr},${pr} 0 0 1 ${pillW},${pr}`,
`L ${pillW},${bodyTop}`,
`Q ${pillW},${bodyTop + curve} ${qEndX},${bodyTop + curve}`,
`H ${bodyW - cr}`,
`A ${cr},${cr} 0 0 1 ${bodyW},${bodyTop + curve + cr}`,
`L ${bodyW},${bodyH - cr}`,
`A ${cr},${cr} 0 0 1 ${bodyW - cr},${bodyH}`,
`H ${cr}`,
`A ${cr},${cr} 0 0 1 0,${bodyH - cr}`,
'Z',
].join(' ')
}
const center = bw / 2
const half = pillW / 2 + ((bw - pillW) / 2) * Math.min(t, 1.1)
const left = center - half
const right = center + half
const qLeft = Math.max(left + cr, pillOffset - curve)
const qRight = Math.min(right - cr, pillOffset + pillW + curve)
return [
`M ${pillOffset},${pr}`,
`A ${pr},${pr} 0 0 1 ${pillOffset + pr},0`,
`H ${pillOffset + pillW - pr}`,
`A ${pr},${pr} 0 0 1 ${pillOffset + pillW},${pr}`,
`L ${pillOffset + pillW},${bodyTop}`,
`Q ${pillOffset + pillW},${bodyTop + curve} ${qRight},${bodyTop + curve}`,
`H ${right - cr}`,
`A ${cr},${cr} 0 0 1 ${right},${bodyTop + curve + cr}`,
`L ${right},${bodyH - cr}`,
`A ${cr},${cr} 0 0 1 ${right - cr},${bodyH}`,
`H ${left + cr}`,
`A ${cr},${cr} 0 0 1 ${left},${bodyH - cr}`,
`L ${left},${bodyTop + curve + cr}`,
`A ${cr},${cr} 0 0 1 ${left + cr},${bodyTop + curve}`,
`H ${qLeft}`,
`Q ${pillOffset},${bodyTop + curve} ${pillOffset},${bodyTop}`,
'Z',
].join(' ')
}
function Icon({ type }: { type: GooeyToastType }) {
const common = { viewBox: '0 0 24 24', className: 'size-[18px]', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true }
if (type === 'success')
return (
<svg {...common}>
<circle cx="12" cy="12" r="9.5" strokeWidth="1.8" opacity="0.4" />
<path d="m7.8 12.4 3 3 5.4-6.2" />
</svg>
)
if (type === 'error')
return (
<svg {...common}>
<circle cx="12" cy="12" r="9.5" strokeWidth="1.8" opacity="0.4" />
<path d="M12 7.5v5.5M12 16.3v.2" />
</svg>
)
if (type === 'warning')
return (
<svg {...common}>
<path d="M12 3.5 21 19H3L12 3.5Z" strokeWidth="1.8" opacity="0.4" />
<path d="M12 9.5v4.5M12 17v.2" />
</svg>
)
if (type === 'loading')
return (
<svg {...common} className="size-[18px] mtg-spin">
<circle cx="12" cy="12" r="8.5" strokeWidth="2.4" opacity="0.25" />
<path d="M12 3.5a8.5 8.5 0 0 1 8.5 8.5" />
</svg>
)
return (
<svg {...common}>
<circle cx="12" cy="12" r="9.5" strokeWidth="1.8" opacity="0.4" />
<path d="M12 11v5.5M12 7.8v.2" />
</svg>
)
}
/**
* 形变通知:先是一颗小胶囊从顶部落下,停一下,再从胶囊下面「长」出一个带说明和按钮的主体,
* 过一会儿收回胶囊并退场。轮廓是一条会变形的 SVG 路径,弹簧带一点过冲,读起来像一滴水。
*/
export function ToastGooey({ className, style, toast, onDismiss, ...props }: ToastGooeyProps) {
const { align, width, fill, bounce, expandDelay, hold, richColors, border } = { ...defaults, ...props }
const reduced = Boolean(useReducedMotion())
return (
<div
className={cn('flex w-full', align === 'left' ? 'justify-start' : align === 'right' ? 'justify-end' : 'justify-center', className)}
style={style}
>
<AnimatePresence mode="wait">
{toast && (
<motion.div
key={toast.id}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: -28, scale: 0.7 }}
animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -18, scale: 0.85, transition: { duration: 0.22, ease: [0.4, 0, 1, 1] } }}
transition={reduced ? { duration: 0.18 } : { type: 'spring', visualDuration: 0.5, bounce: Math.min(bounce, 0.6) }}
style={{ transformOrigin: align === 'center' ? 'top center' : align === 'left' ? 'top left' : 'top right' }}
>
<GooeyBody toast={toast} align={align} width={width} fill={fill} bounce={bounce} expandDelay={expandDelay} hold={hold} richColors={richColors} border={border} reduced={reduced} onDismiss={onDismiss} />
</motion.div>
)}
</AnimatePresence>
</div>
)
}
type BodyProps = {
toast: GooeyToastData
align: string
width: number
fill: string
bounce: number
expandDelay: number
hold: number
richColors: boolean
border: boolean
reduced: boolean
onDismiss?: () => void
}
function GooeyBody({ toast, align, width, fill, bounce, expandDelay, hold, richColors, border, reduced, onDismiss }: BodyProps) {
const type = toast.type ?? 'default'
const centered = align === 'center'
const mirrored = align === 'right'
const hasBody = Boolean(toast.description || toast.action)
const t = useMotionValue(0)
const pathRef = useRef<SVGPathElement>(null)
const frameRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const pillRef = useRef<HTMLDivElement>(null)
const [dims, setDims] = useState({ pw: 120, th: PH + 60 })
const dimsRef = useRef(dims)
dimsRef.current = dims
const [hovered, setHovered] = useState(false)
const pausedRef = useRef(false)
pausedRef.current = hovered
const dismissRef = useRef(onDismiss)
dismissRef.current = onDismiss
const accent = ACCENT[type]
const baseFill = richColors && accent && type !== 'loading' ? `color-mix(in oklab, ${accent} 16%, ${fill})` : fill
const dark = isDark(fill)
const text = dark ? '#fafafa' : '#18181b'
const muted = dark ? '#a1a1aa' : '#52525b'
const stroke = border ? (dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)') : 'none'
// 把当前的 t 画到 DOM 上:路径、容器高度、主体的淡入
const paint = useCallback(() => {
const { pw, th } = dimsRef.current
const v = Math.max(0, t.get())
pathRef.current?.setAttribute('d', morphPath(pw, width, th, v, centered))
if (frameRef.current) frameRef.current.style.height = `${PH + (th - PH) * Math.min(v, 1.1)}px`
if (bodyRef.current) {
const p = Math.min(Math.max((v - 0.3) / 0.6, 0), 1)
bodyRef.current.style.opacity = String(p)
bodyRef.current.style.transform = reduced ? 'none' : `translateY(${(1 - p) * -8}px)`
bodyRef.current.style.pointerEvents = p > 0.9 ? 'auto' : 'none'
}
}, [t, width, centered, reduced])
useMotionValueEvent(t, 'change', paint)
// 测量:胶囊宽度取决于图标和标题;主体高度取决于说明文字换行
useLayoutEffect(() => {
const pill = pillRef.current
const body = bodyRef.current
if (!pill) return
const measure = () => {
const pw = Math.ceil(pill.offsetWidth)
const bodyHeight = body && hasBody ? body.offsetHeight : 0
setDims((current) => {
const th = hasBody ? PH + 10 + bodyHeight + 16 : PH
return current.pw === pw && current.th === th ? current : { pw, th }
})
}
measure()
const observer = new ResizeObserver(measure)
observer.observe(pill)
if (body) observer.observe(body)
return () => observer.disconnect()
}, [hasBody, width, toast.title, toast.description])
useLayoutEffect(paint, [dims, paint])
// 一轮的时间线:落地 → expandDelay 后展开 → hold 秒后收起 → 收起完成后通知调用方。
// 用 rAF + performance.now 计时(悬停时暂停),手动时钟下也是确定的。
useEffect(() => {
t.set(0)
if (toast.type === 'loading') return
let frame = 0
let last = performance.now()
let elapsed = 0
let phase = 0
let controls: ReturnType<typeof animate> | null = null
const expandAt = expandDelay
const collapseAt = expandDelay + hold
const endAt = hasBody ? collapseAt + COLLAPSE_SECONDS : expandDelay + hold
const tick = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.1)
last = now
if (!pausedRef.current && !document.hidden) elapsed += dt
if (phase === 0 && hasBody && elapsed >= expandAt) {
phase = 1
controls = reduced ? animate(t, 1, { duration: 0.2, ease: 'easeOut' }) : animate(t, 1, { type: 'spring', visualDuration: 0.65, bounce: Math.min(bounce, 0.8) })
}
if (phase === 1 && elapsed >= collapseAt) {
phase = 2
controls?.stop()
controls = reduced ? animate(t, 0, { duration: 0.2, ease: 'easeOut' }) : animate(t, 0, { type: 'spring', visualDuration: COLLAPSE_SECONDS * 0.8, bounce: Math.min(bounce * 0.5, 0.4) })
}
if (elapsed >= endAt && phase !== 3) {
phase = 3
dismissRef.current?.()
return
}
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => {
cancelAnimationFrame(frame)
controls?.stop()
}
}, [toast.id, toast.type, hasBody, expandDelay, hold, bounce, reduced, t])
return (
<div
ref={frameRef}
className="relative"
style={{ width, height: PH, color: text }}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={() => setHovered(true)}
onBlur={() => setHovered(false)}
>
<svg width={width} height="100%" className="absolute left-0 top-0 overflow-visible" style={{ transform: mirrored ? 'scaleX(-1)' : undefined, filter: 'drop-shadow(0 10px 18px rgba(0,0,0,0.35)) drop-shadow(0 2px 4px rgba(0,0,0,0.25))' }} aria-hidden>
<path ref={pathRef} d={morphPath(dims.pw, width, dims.th, 0, centered)} fill={baseFill} stroke={stroke} strokeWidth="1" />
</svg>
<div className="absolute top-0 flex" style={{ height: PH, left: 0, right: 0, justifyContent: centered ? 'center' : mirrored ? 'flex-end' : 'flex-start' }}>
<div ref={pillRef} className="flex items-center gap-2 whitespace-nowrap px-4 text-[13px] font-medium" style={{ height: PH, maxWidth: width }}>
<span className="grid shrink-0 place-items-center" style={{ color: accent ?? muted }} aria-hidden>
<Icon type={type} />
</span>
<span className="truncate" aria-hidden>
{toast.title}
</span>
</div>
</div>
<div ref={bodyRef} className="absolute inset-x-0 opacity-0" style={{ top: PH + 10, padding: '0 18px' }}>
{toast.description && (
<p className="text-[13px] leading-[19px]" style={{ color: muted }} aria-hidden>
{toast.description}
</p>
)}
{toast.action && (
<div className={cn('mt-3', centered ? 'flex justify-center' : mirrored ? 'flex justify-end' : 'flex')}>
<button
type="button"
onClick={toast.action.onClick}
className="rounded-full px-3.5 py-1.5 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring"
style={{ background: accent && type !== 'loading' ? `color-mix(in oklab, ${accent} 22%, transparent)` : dark ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.08)', color: accent && type !== 'loading' ? accent : text }}
>
{toast.action.label}
</button>
</div>
)}
</div>
<span role={type === 'error' ? 'alert' : 'status'} aria-live={type === 'error' ? 'assertive' : 'polite'} className="sr-only">
{toast.title}
{toast.description ? `. ${toast.description}` : ''}
</span>
</div>
)
}

More like this

More Toasts →