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…

What is this?A reference implementation of three liquid, goo-style UI interactions (anchored dropdown, morphing dropdown, and speed dial) that all share one gooey gesture engine built with React, SVG filters and GSAP springs.
Is this a component library I can install, or just a demo to read?It is explicitly not a package. It's a reference implementation meant to be read as source code to learn how a production-quality gooey interaction is built (SVG filter, border trick, spring choreography, shared grab gesture).
How do I run it?It's a Vite project: run the dev script to launch it locally, then click the plus button to trigger the interactions, and try holding/dragging the button or an open panel.
What does it depend on?It's built on React with GSAP for spring/easing curves (CustomEase polylines) and plain SVG/CSS filters for the goo effect — no animation libraries beyond GSAP.
Can I just copy one component into my app?Not cleanly — the gesture engine, spring curves, rim math, theme system and color tokens are deliberately shared code living in liquid/ and styles/tokens.css, so the pieces are designed to be read and understood together rather than lifted individually.
Does this run in any browser, or does it need something special?It's a web-based React app rendered with SVG filters and CSS, so it runs in the browser; it also respects prefers-reduced-motion for accessibility.
Why does the goo border not blur into a fat blob when things move?Instead of a stroke, the rim is drawn as the gap between two iso-alpha contours of the blurred shape, with a blur-specific threshold pair precomputed offline so the rendered border stays 1px regardless of blur radius.
How does the colored joint between two merging drops work?Each drop lights its own side of a contact (one lobe per body, not per pair) once rims have genuinely crossed, using a latch with a 5px hysteresis gap so the weld does not flicker during a drag.
Does it have sound?Yes — every sound is synthesized live (oscillator, resonant lowpass, LFO wobble, noise smack, envelope) rather than played from samples, so no two gestures sound identical; it can be run silently if desired.
What's the difference between the light and dark frame?The light frame is monochrome ink-on-grey where only the liquid itself expresses motion, while the dark frame gives each shape its own hue, lights welded joints with a bloom, and uses a deeper, wetter synthesized voice.
Creator
arknow91
Size
0.2 MB
Stars
164
Updated
CommitAug 20
Age
1mo
Commits · 90 Days