Viscosity Menu
Customize
Custom
Speed1.00
Goo blur10.0
Goo contrast24
Lead move stiffness230
Lead move damping19.0
Lead size stiffness115
Lead size damping13.0
Trail move stiffness85
Trail move damping12.0
Trail size stiffness75
Trail size damping11.5
Stretch lead0.42
Stretch trail0.30
Stretch max0.32
Squish0.38
Sway0
Text delay0.20s
Text duration0.30s
Text blur6.0px
Text scale0.85
Usage
import { ViscosityMenu } from "@/components/uibrik/viscosity-menu/viscosity-menu";
<ViscosityMenu />Installation
npx shadcn@latest add https://uibrik.com/r/viscosity-menu.jsonInstall the packages
npm install @tabler/icons-react motionCopy the source into your project
components/uibrik/viscosity-menu/viscosity-menu.tsx"use client"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { MotionConfig, motion } from "motion/react"; import { IconDotsFilled, IconDownloadFilled, IconLinkFilled, IconPencilFilled, IconTrashFilled } from "@tabler/icons-react"; // Centre-based box the liquid springs toward. type Box = { cx: number; cy: number; w: number; h: number; rx: number }; type Key = keyof Box; const KEYS: Key[] = ["cx", "cy", "w", "h", "rx"]; // Group is 224 x 224. The dot sits at the top, the panel 16px below it. const DOT: Box = { cx: 112, cy: 22, w: 44, h: 44, rx: 22 }; const PANEL_GUESS: Box = { cx: 112, cy: 142, w: 132, h: 164, rx: 16 }; // Position is quick and size is slow, so the liquid leaves as a small droplet // and only then swells into the panel. The trail blob is much softer: it lags // behind and the goo filter stretches a neck between the two. type Spring = { k: number; c: number }; type BlobDial = { moveStiffness: number; moveDamping: number; sizeStiffness: number; sizeDamping: number }; const springsFor = (d: BlobDial): Record<Key, Spring> => { const move = { k: d.moveStiffness, c: d.moveDamping }; const size = { k: d.sizeStiffness, c: d.sizeDamping }; return { cx: move, cy: move, w: size, h: size, rx: { k: 200, c: 22 } }; }; // Variations picked from the dial. Choosing one writes its values into the // sliders, so you can start from a preset and keep tweaking. type Preset = { speed: number; goo: { blur: number; contrast: number }; lead: BlobDial; trail: BlobDial; stretch: { lead: number; trail: number; max: number }; squish: number; sway: number; }; export const PRESETS: Record<string, Preset> = { apple: { speed: 1, goo: { blur: 9, contrast: 26 }, lead: { moveStiffness: 260, moveDamping: 21, sizeStiffness: 150, sizeDamping: 15 }, trail: { moveStiffness: 120, moveDamping: 15, sizeStiffness: 100, sizeDamping: 13 }, stretch: { lead: 0.25, trail: 0.2, max: 0.2 }, squish: 0.38, sway: 0, }, snappy: { speed: 1, goo: { blur: 7, contrast: 30 }, lead: { moveStiffness: 380, moveDamping: 26, sizeStiffness: 240, sizeDamping: 20 }, trail: { moveStiffness: 200, moveDamping: 22, sizeStiffness: 170, sizeDamping: 19 }, stretch: { lead: 0.15, trail: 0.1, max: 0.15 }, squish: 0.28, sway: 0, }, island: { speed: 1, goo: { blur: 8, contrast: 28 }, lead: { moveStiffness: 320, moveDamping: 24, sizeStiffness: 200, sizeDamping: 17 }, trail: { moveStiffness: 160, moveDamping: 18, sizeStiffness: 130, sizeDamping: 15 }, stretch: { lead: 0.2, trail: 0.15, max: 0.18 }, squish: 0.5, sway: 0, }, glide: { speed: 1, goo: { blur: 8, contrast: 28 }, lead: { moveStiffness: 420, moveDamping: 36, sizeStiffness: 280, sizeDamping: 28 }, trail: { moveStiffness: 260, moveDamping: 30, sizeStiffness: 200, sizeDamping: 26 }, stretch: { lead: 0.22, trail: 0.18, max: 0.2 }, squish: 0.4, sway: 0, }, silk: { speed: 1, goo: { blur: 9, contrast: 26 }, lead: { moveStiffness: 340, moveDamping: 37, sizeStiffness: 240, sizeDamping: 31 }, trail: { moveStiffness: 200, moveDamping: 28, sizeStiffness: 150, sizeDamping: 24.5 }, stretch: { lead: 0.25, trail: 0.2, max: 0.22 }, squish: 0.34, sway: 0, }, crisp: { speed: 1, goo: { blur: 7, contrast: 30 }, lead: { moveStiffness: 520, moveDamping: 40, sizeStiffness: 360, sizeDamping: 33 }, trail: { moveStiffness: 320, moveDamping: 34, sizeStiffness: 240, sizeDamping: 30 }, stretch: { lead: 0.16, trail: 0.12, max: 0.16 }, squish: 0.42, sway: 0, }, softIsland: { speed: 1, goo: { blur: 9, contrast: 26 }, lead: { moveStiffness: 300, moveDamping: 30, sizeStiffness: 190, sizeDamping: 24 }, trail: { moveStiffness: 150, moveDamping: 24, sizeStiffness: 110, sizeDamping: 20 }, stretch: { lead: 0.2, trail: 0.15, max: 0.18 }, squish: 0.5, sway: 0, }, }; type Blob = { x: Box; v: Box }; const makeBlob = (b: Box): Blob => ({ x: { ...b }, v: { cx: 0, cy: 0, w: 0, h: 0, rx: 0 } }); const options = [ { label: "Edit", icon: <IconPencilFilled className="size-5" />, className: "hover:text-zinc-950 dark:hover:text-zinc-50" }, { label: "Download", icon: <IconDownloadFilled />, className: "hover:text-zinc-950 dark:hover:text-zinc-50" }, { label: "Copy Link", icon: <IconLinkFilled />, className: "hover:text-zinc-950 dark:hover:text-zinc-50" }, { label: "Delete", icon: <IconTrashFilled />, className: "text-red-400 hover:text-red-600 dark:hover:text-red-300" }, ]; export type ViscosityPreset = "apple" | "snappy" | "island" | "glide" | "silk" | "crisp" | "softIsland"; export type ViscosityMenuProps = { /** Load a tuned variation; it overrides the matching values below. */ preset?: ViscosityPreset | ""; speed?: number; gooBlur?: number; gooContrast?: number; leadMoveStiffness?: number; leadMoveDamping?: number; leadSizeStiffness?: number; leadSizeDamping?: number; trailMoveStiffness?: number; trailMoveDamping?: number; trailSizeStiffness?: number; trailSizeDamping?: number; stretchLead?: number; stretchTrail?: number; stretchMax?: number; clipText?: boolean; squish?: number; sway?: number; textDelay?: number; textDuration?: number; textBlur?: number; textScale?: number; }; export function ViscosityMenu({ preset = "", speed = 1, gooBlur = 10, gooContrast = 24, leadMoveStiffness = 230, leadMoveDamping = 19, leadSizeStiffness = 115, leadSizeDamping = 13, trailMoveStiffness = 85, trailMoveDamping = 12, trailSizeStiffness = 75, trailSizeDamping = 11.5, stretchLead = 0.42, stretchTrail = 0.3, stretchMax = 0.32, clipText = true, squish = 0.38, sway = 0, textDelay = 0.2, textDuration = 0.3, textBlur = 6, textScale = 0.85, }: ViscosityMenuProps) { // Same shape the DialKit panel produced; a preset writes over its part of it. const values = { speed, goo: { blur: gooBlur, contrast: gooContrast }, lead: { moveStiffness: leadMoveStiffness, moveDamping: leadMoveDamping, sizeStiffness: leadSizeStiffness, sizeDamping: leadSizeDamping }, trail: { moveStiffness: trailMoveStiffness, moveDamping: trailMoveDamping, sizeStiffness: trailSizeStiffness, sizeDamping: trailSizeDamping }, stretch: { lead: stretchLead, trail: stretchTrail, max: stretchMax }, clipText, squish, sway, text: { delay: textDelay, duration: textDuration, blur: textBlur, scale: textScale }, }; const p = preset && PRESETS[preset] ? { ...values, ...PRESETS[preset] } : values; const live = useRef(p); useEffect(() => { live.current = p; }); const hidden = { opacity: 0, filter: `blur(${p.text.blur}px)`, scale: p.text.scale }; const shown = { opacity: 1, filter: "blur(0px)", scale: 1 }; const enter = { duration: p.text.duration, ease: [0.23, 1, 0.32, 1] as const, delay: p.text.delay }; const exit = { duration: 0.14, ease: "easeIn" as const }; const [open, setOpen] = useState(false); const [panel, setPanel] = useState<Box>(PANEL_GUESS); const groupRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null); const leadEl = useRef<SVGRectElement>(null); const trailEl = useRef<SVGRectElement>(null); const blobs = useRef<{ lead: Blob; trail: Blob }>({ lead: makeBlob(DOT), trail: makeBlob(DOT) }); const target = useRef<Box>(DOT); const raf = useRef(0); const wasOpen = useRef(false); // Read the real panel size so the liquid always matches the DOM. useLayoutEffect(() => { const group = groupRef.current; const el = panelRef.current; if (!group || !el) return; const measure = () => { const g = group.getBoundingClientRect(); const r = el.getBoundingClientRect(); const next = { cx: r.left - g.left + r.width / 2, cy: r.top - g.top + r.height / 2, w: r.width, h: r.height, rx: 16 }; setPanel((p) => Math.abs(p.cx - next.cx) < 0.5 && Math.abs(p.cy - next.cy) < 0.5 && Math.abs(p.w - next.w) < 0.5 && Math.abs(p.h - next.h) < 0.5 ? p : next, ); }; measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); useEffect(() => { const draw = (blob: Blob, el: SVGRectElement | null, stretch: number, moving = true, clipText = false) => { if (!el) return; const { x, v } = blob; // Volume-preserving squash and stretch along the direction of travel. const r = 1 + Math.min(Math.abs(v.cy) * stretch * 0.001, live.current.stretch.max); // Apple-style squish: narrowest halfway between dot and panel. const travel = Math.min(Math.max((x.cy - DOT.cy) / (panel.cy - DOT.cy), 0), 1); const bell = moving ? Math.sin(Math.PI * travel) : 0; const squish = live.current.squish * bell; const h = x.h * r * (1 + squish * 0.5); const w = Math.max((x.w / r) * (1 - squish), 1); el.setAttribute("x", String(x.cx - w / 2)); el.setAttribute("y", String(x.cy - h / 2)); el.setAttribute("width", String(w)); el.setAttribute("height", String(h)); const rx = Math.max(Math.min(x.rx, w / 2, h / 2), 0); el.setAttribute("rx", String(rx)); // Reveal the options only where the liquid actually is. const panelEl = panelRef.current; if (clipText && panelEl) { if (!moving || !live.current.clipText) { panelEl.style.clipPath = ""; } else { const px = panel.cx - panel.w / 2; const py = panel.cy - panel.h / 2; const l = Math.max(x.cx - w / 2 - px, 0); const t = Math.max(x.cy - h / 2 - py, 0); const r = Math.max(px + panel.w - (x.cx + w / 2), 0); const b = Math.max(py + panel.h - (x.cy + h / 2), 0); panelEl.style.clipPath = l + r >= panel.w || t + b >= panel.h ? "inset(50%)" : `inset(${t}px ${r}px ${b}px ${l}px round ${rx}px)`; } } }; const settled = (blob: Blob) => KEYS.every((k) => Math.abs(blob.v[k]) < 0.05 && Math.abs(blob.x[k] - target.current[k]) < 0.05); const snap = () => { for (const blob of [blobs.current.lead, blobs.current.trail]) { blob.x = { ...target.current }; blob.v = { cx: 0, cy: 0, w: 0, h: 0, rx: 0 }; } draw(blobs.current.lead, leadEl.current, 0, false, true); draw(blobs.current.trail, trailEl.current, 0, false); }; const integrate = (blob: Blob, springs: Record<Key, Spring>, dt: number) => { for (const k of KEYS) { const { k: stiffness, c } = springs[k]; blob.v[k] += (-stiffness * (blob.x[k] - target.current[k]) - c * blob.v[k]) * dt; blob.x[k] += blob.v[k] * dt; } }; const tick = (prev: number) => (now: number) => { const frame = Math.min((now - prev) / 1000, 1 / 30) * live.current.speed; const steps = Math.max(1, Math.ceil(frame / (1 / 240))); const dt = frame / steps; for (let i = 0; i < steps; i++) { integrate(blobs.current.lead, springsFor(live.current.lead), dt); integrate(blobs.current.trail, springsFor(live.current.trail), dt); } draw(blobs.current.lead, leadEl.current, live.current.stretch.lead, true, true); draw(blobs.current.trail, trailEl.current, live.current.stretch.trail); if (settled(blobs.current.lead) && settled(blobs.current.trail)) { snap(); raf.current = 0; return; } raf.current = requestAnimationFrame(tick(now)); }; target.current = open ? panel : DOT; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { snap(); } else { if (wasOpen.current !== open) { const dir = open ? 1 : -1; blobs.current.lead.v.cx += live.current.sway * dir; blobs.current.trail.v.cx += live.current.sway * 0.6 * dir; } if (!raf.current) raf.current = requestAnimationFrame(tick(performance.now())); } wasOpen.current = open; }, [open, panel]); useEffect(() => () => cancelAnimationFrame(raf.current), []); return ( <MotionConfig reducedMotion="user"> <div ref={groupRef} className="relative w-56 h-56"> <svg className="absolute inset-0 overflow-visible pointer-events-none" width="224" height="224" aria-hidden> <defs> <filter id="menu-goo" filterUnits="userSpaceOnUse" x="-60" y="-60" width="344" height="344" colorInterpolationFilters="sRGB"> <feGaussianBlur in="SourceGraphic" stdDeviation={p.goo.blur} result="blur" /> <feColorMatrix in="blur" type="matrix" values={`1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${p.goo.contrast} ${-p.goo.contrast * 0.46}`} /> </filter> </defs> <g filter="url(#menu-goo)" fill="var(--muted)"> <rect ref={trailEl} x={DOT.cx - DOT.w / 2} y={DOT.cy - DOT.h / 2} width={DOT.w} height={DOT.h} rx={DOT.rx} /> <rect ref={leadEl} x={DOT.cx - DOT.w / 2} y={DOT.cy - DOT.h / 2} width={DOT.w} height={DOT.h} rx={DOT.rx} /> </g> </svg> <div className="absolute top-0 left-1/2 -translate-x-1/2 rounded-3xl"> <button type="button" aria-label="Open menu" aria-expanded={open} inert={open} className="block p-2.5 cursor-pointer" onClick={() => setOpen(true)} > <motion.span className="block" initial={false} animate={ open ? { ...hidden, transition: exit } : { ...shown, transition: enter } } > <IconDotsFilled className="size-6" /> </motion.span> </button> </div> <div ref={panelRef} inert={!open} className="absolute top-15 left-1/2 -translate-x-1/2 p-4 rounded-2xl"> <motion.div className="flex flex-col items-start gap-3 font-medium text-zinc-500 dark:text-zinc-400" style={{ originY: 0 }} initial={false} animate={ open ? { ...shown, transition: enter } : { ...hidden, transition: exit } } > {options.map(({ label, icon, className }) => ( <button key={label} type="button" className={`flex items-center justify-center gap-2 cursor-pointer ${className}`} onClick={() => setOpen(false)} > {icon} <p>{label}</p> </button> ))} </motion.div> </div> </div> </MotionConfig> ); }
Adds the files to
components/uibrik/viscosity-menu and installs @tabler/icons-react and motion. With the @uibrik namespace set up: npx shadcn@latest add @uibrik/viscosity-menu.Props
ViscosityMenu- preset
- Type "" | "apple" | "snappy" | "island" | "glide" | "silk" | "crisp" | "softIsland"
- ""
- A tuned variation; overrides goo, springs, stretch, squish and sway.
- speed
- Type number
- 1
- gooBlur
- Type number
- 10
- gooContrast
- Type number
- 24
- leadMoveStiffness
- Type number
- 230
- leadMoveDamping
- Type number
- 19
- leadSizeStiffness
- Type number
- 115
- leadSizeDamping
- Type number
- 13
- trailMoveStiffness
- Type number
- 85
- trailMoveDamping
- Type number
- 12
- trailSizeStiffness
- Type number
- 75
- trailSizeDamping
- Type number
- 11.5
- stretchLead
- Type number
- 0.42
- stretchTrail
- Type number
- 0.3
- stretchMax
- Type number
- 0.32
- clipText
- Type boolean
- true
- squish
- Type number
- 0.38
- sway
- Type number
- 0
- textDelay
- Type number
- 0.2
- textDuration
- Type number
- 0.3
- textBlur
- Type number
- 6
- textScale
- Type number
- 0.85