liquid-taffy README

The project's README from github.com/arknow91/liquid-taffy. As one markdown file: README.md.

liquid-taffy

Three liquid interactions you can grab, pull, and let snap back — on two frames.

One small plus-button, three ways for a menu to come out of it — and every surface on screen behaves like taffy: press and drag any of them and a liquid finger stretches out of the edge, follows your cursor against growing tension, and whips back into the rim when you let go.

You land on both frames at once: a switch at the top carries the whole stage between a light room and a dark one. They are not two colour schemes over one design. On the light frame the family is monochrome and the liquid speaks for itself; the dark frame is where the pad's colour language lives — every drop carries its own hue, joints light in the colours of the two bodies making them, and a completed merge catches under the glyph.

This is a reference implementation, not a package. It exists so you can read the source and see exactly how a production-quality gooey interaction is put together: the SVG filter, the border trick, the spring choreography, and the shared grab gesture. Everything here is built around one question — not "how do surfaces melt into each other" but "how does one surface feel like it has a body".

The three interactions

Interaction What the button does
Anchored dropdown Stays put and pours a dropdown out of itself, hanging 14px above — the drop leaps upward on a pop spring, rows condense bottom-up.
Morphing dropdown Becomes the dropdown. The circle stretches upward, its mass drawn into the pour; closing gathers the panel, necks it, and dives it back into the circle — splat included.
Speed dial Breaks into three droplets that ooze out of the button, launch past full size, and ring themselves still.

All three share one trigger, one visual language, and — literally — one gesture engine.

Both dropdowns are multi-select: a row keeps the menu open and takes a tick, its glyph spinning away as the tick grows out of it. Neighbouring selections stop being separate shapes — the radii between them collapse and they pour into one rounded run. Tick all four and the pad's glyphs take a bow from behind the panel.

The two frames

Light Dark
Colour none — the surfaces are ink on grey one hue per body: circle red, cross blue-violet, square pink, triangle green
Joints the border stays the border each body lights ITS OWN side of a contact, so a weld runs one hue into the other
Merge shows in the liquid alone the welded glyphs catch their hue with a bloom
Voice a shallow dish of water — higher, brighter, drier a deep vessel of syrup — lower, darker, wetter, longer

The speed dial's satellites sit in the pad's own compass — square left, triangle up, circle right, the cross in the middle as the trigger.

The frame itself is one thing kept in two halves that cannot disagree: every colour either frame wears lives in src/styles/tokens.css, and the frame as a value lives in src/components/liquid/theme.ts. No component stylesheet names a colour and no component takes a theme prop — see One palette, one word.

Run it

npm install
npm run dev

Then click the plus. Also: hold and drag the plus, a satellite, or the open panel — that's the part you can't screenshot. Then switch the frame at the top and do it again: on the dark frame, drag one drop into another and watch the border between them light in both their colours at once.

Every gesture also makes a sound (see below). To run it silent:

import { gooSfx } from "./components/liquid/sfx";
gooSfx.mute();          // or gooSfx.volume(0.3)

How it's made

Three layers, one picture at a time

Each component draws its scene twice, stacked:

  1. Crisp bodies — plain CSS circles and an SVG squircle with a real 1px border and a real shadow. This is the resting picture.
  2. The goo — the same shapes cloned into one <svg>, run through a blur → alpha-threshold filter (the classic metaball trick), which draws its own rim and casts one shadow for the whole mass.
  3. Icons and hit areas — transparent buttons riding the same tweens, so glyphs stay crisp above the liquid and accessibility lives in real DOM buttons.

Exactly one of the first two layers is visible at any moment. At rest you are looking at real CSS borders; the instant anything moves, the goo takes over the whole picture; when the motion settles, it hands back. No half-blended states — even a sub-pixel of overlap reads as a doubled border.

The border that never swells

The goo's outline is not a stroke. It is the sliver between two iso-alpha contours of the same blurred alpha — an outer threshold that lands on the border's outer edge and an inner one a hair inside it. How far apart those contours land in pixels depends on the blur σ, so a threshold pair that draws a 1px rim at σ=1 draws a fat, misplaced one at σ=7. That is why gooey buttons in the wild appear to inflate the moment they start moving.

Here every working blur carries its own threshold pair, solved offline by rasterizing the exact filter over a 32px disc and integrating the rim's ink until its outer edge sat on the CSS border's outer edge and its weight equalled 1px. Blur and thresholds are switched together, in the same frame, always (src/components/liquid/goo.ts). The result: one border, whatever the liquid does.

The stretch is one engine

The grab gesture — press, drag, snap back — is a single implementation shared verbatim by all three interactions (src/components/liquid/stretch.ts):

One subtlety worth stealing: while a dropdown is closed, its panel hides inside the button, scaled to ~0.1. During a grab that hidden mass must sit the gesture out — it can't ride the button's lean, so left in the goo it pokes out of the moving silhouette as a hump. The engine's host contract has a hook for exactly this.

Springs, not durations

Nothing here eases with a stock curve. Two physical springs are sampled into GSAP CustomEase polylines (src/components/liquid/springs.ts):

Entrances overshoot and ring; exits are authored, not reversed — big surfaces wind up ~10% the wrong way (anticipate) before collapsing, and whatever lands on the button is absorbed with an impact squash. Colors never spring.

The joint light

Drag one drop toward another and, a few pixels before they touch, the border between them starts telling you about it (src/components/liquid/seam.ts). Nothing is painted on top of the picture: the rim keeps being the rim, it just runs colour through the joint and back out again a little way along each drop.

The engine reports where two rims meet, how strongly, and whether they have genuinely crossed rather than merely leaned. Three things in it are worth stealing:

The light belongs to the dark frame. On the light frame the engine still runs — its report is not only paint; it is what the glyphs and the voice answer to — it simply has nothing to draw.

The voice

Every sound is synthesized on the spot (src/components/liquid/sfx.ts), no samples, so a repeated gesture never sounds like one file played twice. It is one little machine shaped ten ways:

A frame is described as multipliers on that shape, never a second set of numbers, so the two rooms cannot drift apart. Each of the dial's drops speaks in its own pitch, so a contact tells you which contact it is without looking.

One palette, one word

Light and dark used to be four things: a "light" | "dark" alias re-declared in every component, a prop threaded down through two stages, a ref copied into each surface so long-lived callbacks could ask which frame they were speaking in, and a palette hand-copied into four stylesheets. Copies drift — two of those stylesheets had already landed a percent apart on the same hover tint.

They are one thing now:

What stays in TypeScript rather than CSS is what more than CSS reads. liquid/hues.ts holds the motif — one hue per glyph per frame — because the joint's gradient, a glyph's glow and the burst all need those values in JS, and with them the rule that makes the light frame monochrome: there motifHue returns null, and every consumer falls back to its own ink. One function, so the two frames can never drift.

The same tidying reached the two dropdowns' stylesheets. Once neither of them named a colour, they were the same file twice over — so the surface they share (bodies, goo, seam, rows, wash, glyph, trigger) is now liquid/dropdown.module.css, and each variant composes it and adds the one thing that makes it itself: where its panel sits, 14px above the button or exactly on it.

Project structure

src/styles/
  tokens.css          THE palette — both frames; nothing else declares a colour
src/components/
  ThemeStage/         the two-frame stage: the switch, and the frame swap
  InteractionStage/   the three surfaces and the pill switcher
  LiquidAdd/          anchored dropdown
  LiquidMorph/        morphing dropdown
  LiquidMenu/         speed dial (+ shared icons)
  PillTabs/           the travelling pill, used by both switch rows
  liquid/
    theme.ts          THE frame, as a value: one type, one context
    stretch.ts        THE grab gesture — one engine for the family
    seam.ts           the joint light: where two rims meet, and how much
    springs.ts        sampled spring curves
    goo.ts            rim calibration: solved thresholds per blur
    squircle.ts       Apple's continuous corner, as a path
    hues.ts           the family's colour table — one per body, per frame
    select.ts         multi-select geometry: how neighbours become one shape
    sfx.ts            the voice — a tiny procedural synth, two characters
    motion.ts         prefers-reduced-motion, asked in one place
    IconMorph.tsx     a glyph spinning away as its tick grows out of it
    RowHover.tsx      one highlight travelling under a list
    SelectionBurst.tsx  the full set, taking its bow
    dropdown.module.css  the two dropdowns' shared surface

Each interaction owns its choreography (open/close timelines, geometry, blur levels); everything that must never drift apart between them — the gesture, the curves, the rim math, the frame, the palette — lives in liquid/ or in styles/tokens.css.

Accessibility

A note on the marks

The four glyphs and the badge on the dark option refer to the PlayStation controller because that is the visual joke the dark frame is built on. PlayStation is a trademark of Sony Interactive Entertainment Inc. This project is an independent demo, is not affiliated with or endorsed by Sony, and is not sold.

Credits

Motion language after Apple's liquid glass. Built with GSAP + React 19 + Vite. Filter tricks descend from the classic SVG gooey-effect lineage — blur, threshold, and a lot of border calibration on top.

MIT — see LICENSE.

liquid-taffy (arknow91/liquid-taffy)

Searching Database…

这是什么?这是三个液态黏滞风格 UI 交互(锚定下拉框、变形下拉框和快捷拨号盘)的参考实现,它们共享同一个由 React、SVG 滤镜和 GSAP 弹簧构建的黏滞手势引擎。
这是我能安装的组件库,还是仅供阅读的演示?它明确不是一个包。它是一个参考实现,意在让人阅读源码,学习一个生产级质量的黏滞交互是如何构建的(SVG 滤镜、边框技巧、弹簧编排、共享的抓取手势)。
怎么运行它?这是一个 Vite 项目:运行 dev 脚本在本地启动,然后点击加号按钮触发交互,还可以试着按住/拖动按钮或已打开的面板。
它依赖什么?它基于 React,用 GSAP 做弹簧/缓动曲线(CustomEase 折线),用纯 SVG/CSS 滤镜实现黏滞效果——除 GSAP 外没有其他动画库。
我可以只把一个组件复制进我的应用吗?不太行——手势引擎、弹簧曲线、边缘计算、主题系统和颜色 token 是有意设计的共享代码,位于 liquid/ 和 styles/tokens.css 中,这些部件是设计成放在一起阅读理解的,而不是为了单个拆走。
它在任何浏览器里都能跑,还是需要特殊条件?它是一个基于 Web 的 React 应用,用 SVG 滤镜和 CSS 渲染,所以在浏览器里运行;它还遵循 prefers-reduced-motion 以照顾无障碍需求。
元素移动时,黏滞边框为什么不会糊成一团粗胖的色块?边缘不是用描边画的,而是画成模糊形状两条等 alpha 轮廓之间的间隙,并离线预计算了针对特定模糊度的阈值对,所以无论模糊半径多大,渲染出的边框都保持 1px。
两滴液体融合时,彩色的连接处是怎么实现的?一旦两个边缘真正相交,每滴液体就点亮接触面自己的一侧(每个物体一个叶瓣,而不是每对一个),并使用带 5px 迟滞间隙的锁存器,使拖动过程中焊接处不会闪烁。
它有声音吗?有——所有声音都是实时合成的(振荡器、共振低通、LFO 摆动、噪声拍击、包络),而不是播放采样,所以没有两次手势听起来完全一样;需要的话也可以静音运行。
浅色框架和深色框架有什么区别?浅色框架是灰底上的单色水墨,只有液体本身表达运动;深色框架则给每个形状自己的色调,用泛光点亮焊接处,并使用更低沉、更「湿润」的合成音色。
Creator
arknow91
Size
0.2 MB
Stars
164
Updated
CommitAug 20
Age
2mo
Commits · 90 Days