JGuang

液态形变

Liquid Gooey · Libraries.dev

交互预览
npm install liquid-gooey@0.2.2

液态形变

Libraries.dev 的独立组件,请不要替换为 JGuang UI/Base UI 的同名 API。

安装

npm install liquid-gooey@0.2.2

安装包后使用它自己的导出组件,支持 React 18+。Canvas/WebGL 组件需要浏览器环境;提供静态降级,并尊重减少动画偏好。声音预览使用模拟信号,不会自动申请麦克风。

使用与验证

在左侧选择组件,在预览中操作它的状态;使用“查看代码”检查真实源码。安装后执行类型检查和构建,再验证键盘焦点、手机布局与深浅色主题。

来源

https://github.com/Jakubantalik/Libraries.dev/tree/b7f44b8744f4e09ff088d824719636a513110e2a MIT 许可证;不包括付费内容。下方提供上游英文参考内容,中文使用指南已适配;上游详细参考暂保留英文。


适用场景

融合相邻表面或增加液态运动,同时保持内容自身清晰。

关键原生参数

参数说明
fillLiquid 根容器的表面颜色。显式设置根布局,并在内部放置 Liquid.Item。
blur根 blur 控制桥接距离,contrast 锐化边缘;过滤区域越大,栅格化成本越高。
effectLiquid.Item 选择 morph/move/bend/melt;melt 需要图像,不是只放一个空的纯色块。
morphshape=true 开启弹性形变;此演示用 contentBlur=0 保持文字清晰。
x由 Item 控制的位置,另有 y 和 transition;若自己管理布局动画,可不传 x/y。

交互与状态检查

检查融合/分离、四种效果、移动以及裁切/焦点。本地图片使用自有 data URL,正式图片需要正确尺寸,并按场景处理跨域。

业务接入边界

这是 SVG 几何/物理特效,不提供完整下拉菜单、拖拽控制或数据保存。保留自己的按钮、焦点管理和减少动画方案。

示例文件与宿主配置

从源码面板复制 examples 下的组件文件和 shared.tsx,保留它们的相对位置;不是只复制原生上游实现。先安装固定版本 npm 包,在浏览器组件中使用它自己的导出。示例不需要 Tailwind 或新的主题 Provider,采用宿主 html 的语言和主题,并提供减少动画与图形不可用降级。原生实现参考仅供阅读,通过 npm 导入,不要求手工复制内部引擎。示例场景名称不是新增的原生组件属性。下方上游详细参考保留英文,不宣称全文中文化。

可运行用法

以下代码使用资源原生 API;不是把其他设计系统的同名组件替换进来。

"use client";
import { useState } from "react";
import { Liquid, type LiquidEffect } from "liquid-gooey";
import { DemoFrame, GraphicsGuard, control, demoImages, t } from "./shared";
export default function Example({ variant = "Default" }: { variant?: string }) {
  const [effect, setEffect] = useState<LiquidEffect>(variant === "Move" ? "move" : variant === "Bend" ? "bend" : variant === "Melt" ? "melt" : "morph"), [near, setNear] = useState(false), [physics, setPhysics] = useState(variant === "Morph physics");
  const moving = effect === "move" || effect === "bend";
  return <DemoFrame status={`effect=${effect}, merged=${near}, morph.shape=${physics}`} controls={<>
    <label>{t("Effect", "效果")} <select style={control} aria-label={t("Liquid effect", "液态效果")} value={effect} onChange={e => setEffect(e.target.value as LiquidEffect)}>{["morph", "move", "bend", "melt"].map(s => <option key={s}>{s}</option>)}</select></label>
    <button type="button" style={control} aria-pressed={near} onClick={() => setNear(!near)}>{moving ? t("Move shape", "移动形状") : near ? t("Separate", "分离") : t("Merge", "融合")}</button>
    {effect === "morph" && <button type="button" style={control} aria-pressed={physics} onClick={() => setPhysics(!physics)}>{t("Spring physics", "弹性物理")}</button>}
  </>}><GraphicsGuard kind="svg"><Liquid fill="#2F67FF" style={{ display: "flex", gap: moving ? 16 : near ? 0 : 28, alignItems: "center", width: 230, minHeight: 140 }}>
    <Liquid.Item effect={effect} x={moving && near ? 60 : 0} morph={{ shape: physics, contentBlur: 0 }} style={{ borderRadius: 20, width: 88, height: 88, display: "grid", placeItems: "center", color: "white" }}>
      {effect === "melt" ? <img src={demoImages[0]} alt={t("Blue demo image", "蓝色演示图片")} style={{ width: 88, height: 88, borderRadius: 20, objectFit: "cover" }} /> : t("Liquid", "液态")}
    </Liquid.Item>
    <Liquid.Item effect={effect} morph={{ shape: physics, contentBlur: 0 }} style={{ borderRadius: 44, width: 88, height: 88 }}>
      {effect === "melt" ? <img src={demoImages[1]} alt={t("Purple demo image", "紫色演示图片")} style={{ width: 88, height: 88, borderRadius: 44, objectFit: "cover" }} /> : null}
    </Liquid.Item>
  </Liquid></GraphicsGuard></DemoFrame>;
}

可用状态与示例

液态融合、弹性形变、移动拖尾、移动弯曲、图像融合

组件中的状态与数据为演示用途,不包含后端接口。接入实际项目时请检查源码中的公开参数,再连接业务数据。

liquid-gooey

Liquid effects for React UI. Two effects and one modifier cover the whole family:

  • Morph — touching pieces merge gooily and change shape like jelly. Menus, avatar groups, morphing panels.
  • Move — the surface trails a moving element as liquid rubber with a droplet tail. Sliders, tab indicators, dragged things.

Plus dissolve, an orthogonal modifier: your imagery melts into whatever it touches. It isn't a third effect — the effects describe how the surface moves, dissolve describes what your content does where two surfaces meet.

Cross-browser (Safari included), crisp text/icons/images at all times, real shadows on the merged liquid, zero idle cost.

Why

The usual gooey effect runs blur + alpha-contrast over your actual UI. That one decision costs you everything else: text goes soft, images smear, shadows become impossible (the contrast step eats them), and Safari renders it wrong. So the effect stays confined to decorative colored circles.

liquid-gooey splits it in two — a silhouette layer carries the goo and the shadow, your real DOM rides crisp on top. Same liquid, none of the tax.

  • Real shadows. Parsed from box-shadow syntax and rebuilt on the merged silhouette, so one consistent shadow hugs the liquid through every merge, split and bridge — no handoff, nothing to flash.
  • Crisp everything. Text, icons and photos stay pixel-sharp because they are never filtered.
  • Images that actually melt. Opt-in dissolve warps imagery at the contact point with SVG displacement — two materials mixing, not a blur.
  • Safari included. Filters run on SVG content, never CSS url() on HTML — that's the specific thing WebKit gets wrong.
  • It's real UI, not decoration. The content layer is live DOM: focus rings, hit targets, ARIA and handlers all intact.
  • Zero idle cost. Component-driven springs compile to GPU-composited CSS linear() easings; the measurement loop sleeps completely when nothing moves.
  • Two effects, not fifty knobs. Morph and Move take 3–4 normalized knobs each, plus a dissolve modifier — with the full physics underneath when you want it.
npm install liquid-gooey
import { Liquid } from 'liquid-gooey'

<Liquid blur={6} contrast={18} fill="#fff" shadow="0 2px 6px rgba(0,0,0,.08)">
  {/* MORPH (default): give items x/y and the library animates element and
      liquid in perfect sync — a plus-menu that splits like droplets */}
  <Liquid.Item x={open ? -54 : 0} y={open ? -34 : 0} transition="bouncy">
    <button className="round-btn">…</button>
  </Liquid.Item>
  <Liquid.Item x={0} y={open ? -64 : 0} transition="bouncy" delay={40}>
    <button className="round-btn">…</button>
  </Liquid.Item>
</Liquid>

Run the playground: npm install && npm run dev — add ?pro to the URL for the full raw-physics control panels.

The four effects

Morph (default)

Merging is automatic: blobs share the group's goo filter, so touching pieces bridge and merge. One optional power:

// Shape change: the liquid mass flows toward the new centre first, then size
// and corner radius adapt — content cross-blurs inside the moving liquid.
<Liquid.Item morph={{ shape: true }}>
  <div className={open ? 'panel-open' : 'panel-closed'}>…</div>
</Liquid.Item>
KnobDefaultMeaning
shapefalseLiquid shape-change physics (springs + droplet travel + corner timeline).
speed1Tempo multiplier for the shape physics (2 = twice as fast, same character).
bounce0.50..1 — how much the physics overshoot and wobble. 0 = calm, critically damped.
contentBlur7Max px your content cross-blurs by while the liquid is in motion, sharpening as it settles. 0 disables.

Morph acts on both halves of the effect: the surface (merge, springs, corner timeline) and your content (contentBlur while moving).

Dissolve (a modifier, not an effect)

<Liquid.Item dissolve />                      // the tuned look
<Liquid.Item dissolve={0.5} />                // scaled intensity
<Liquid.Item dissolve={{ mix: 0.7, active: dragging }} />   // full DissolveOptions

The item's imagery melts into a touching neighbour at the contact point — a liquid warp with two-liquid mixing, not a blur. Strength ramps with proximity, tracking where the goo bridge actually forms, so it needs no gating of its own; wire active to your drag state if you want it to clear on release. Text is never melted, only imagery.

Today it applies to morph items. Under effect="move" it is ignored with a dev warning: the melt is drawn from the element's measured rect while move's liquid deliberately lags on a spring, so the two would visibly disagree.

Move

<Liquid.Item effect="move" move={{ springiness: 0.5, trail: 0.575 }}>
  <div className="thumb" style={{ transform: `translateX(${x}px)` }} />
</Liquid.Item>

You move the element however you like (CSS, pointer events, Framer Motion) — the liquid trails it.

KnobDefaultMeaning
springiness0.50..1 — how tightly the liquid chases. 0 = heavy syrup, 1 = near-instant.
wobble0.50..1 — overshoot on arrival.
stretch0.360..1 — velocity stretch of the drop.
trail0.5750..1 — trailing droplet size. 0 disables the tail.

All defaults reproduce the library's tuned look exactly — knobs at rest change nothing.

Melt

Two images running molten into each other: the goo averages their colours where they touch, each crisp face dissolves back at the seam, and a marbling pass folds the two palettes into streaks inside the contact area.

<Liquid>
  <Liquid.Item effect="melt">
    <div className="card"><img src={a} /></div>
  </Liquid.Item>
  <Liquid.Item effect="melt">
    <div className="card"><img src={b} /></div>
  </Liquid.Item>
</Liquid>

Move the elements however you like — the melt follows their rendered rects. The source is read from the first <img> inside the item, or passed as melt={{ src }}. Your element stays interactive (drag handlers, focus) but is painted by the melt layer, so style its size and radius as usual.

Pairwise by design: the first two melt items in a group form the pair, because the seam maths is two-body.

KnobDefaultMeaning
blur7Goo sigma — how far the bodies reach, and how wide the colour averaging runs.
contrast40Sharpness of the liquid boundary.
reach0.8How far each crisp face dissolves back before the neighbour.
fade17Softness of that dissolve.
warp0Turbulence displacement of the molten layer (px).
mix10..1 — marbling strength in the touch area.
mixBlur8Blur of the marble pass's source colours: low keeps image detail, high folds pre-averaged soup.
gravity1.9How deep the marble zone reaches into each card.
waviness12Wavelength of the warp noise: low = long lazy rolls, high = fine ripples.

Bend

The body deforms with velocity, like a soft bar pulled through liquid: dragging vertically arcs the long edges (the middle leads, the ends lag), dragging sideways reshapes the rounded caps (the leading one blunts, the trailing one stretches). The surface stays glued to its content — there is no lag and no tail, so text and icons never slide off their own card.

<Liquid.Item effect="bend" bend={{ vertical: 0.6, horizontal: 0.35 }}>
  <div className="card" style={{ transform: `translate(${x}px, ${y}px)` }}>…</div>
</Liquid.Item>
KnobDefaultMeaning
vertical0.60..1 — bow strength of the long edges.
horizontal0.350..1 — cap deformation.

The live bend is published on the item as CSS variables — --lg-bend-x / --lg-bend-y (px) and --lg-bend-xn / --lg-bend-yn (unitless, for deg maths) — so the content can lean, rotate or shear along with the silhouette:

.card > * {
  transform: translateY(calc(var(--lg-bend-y, 0px) * 2))
             rotate(calc(var(--lg-bend-yn, 0) * 1deg));
}

<Liquid> (the group)

Renders a position: relative div; the liquid silhouette svg sits behind all children.

PropDefaultDescription
blur6Goo blur sigma (px). Larger = pieces bridge from farther away. Bridging is a ratio of blur to gap — roughly, neighbours merge once blur ≳ the gap between them. Items 8px apart barely bridge at blur={5} and merge cleanly at blur={12}. If pieces that should merge look separate, raise blur (or close the gap) before suspecting anything else.
contrast18Alpha-contrast slope. Larger = sharper liquid edge.
fill'#fff'Liquid surface color. Any CSS color; var(--surface) works (theming).
shadow—box-shadow syntax, multi-layer supported. Rendered on the merged silhouette, so one consistent shadow hugs the liquid through every merge and split. inset layers paint INSIDE the liquid edge — inner rings and top highlights follow the merged goo too.
filterPadding24Extra filter-region slack (px) if blobs travel outside the group's box.
waviness0Max px the liquid boundary undulates — the silhouette and its shadows run through a gentle noise displacement, so edges read as fluid rather than geometric.
wavinessFreq0.018Noise frequency of that undulation; lower = longer, lazier waves.

Plus all standard div props. Size the group to contain the full travel of its items (like a menu reserving its open footprint).

<Liquid.Item>

PropDescription
effect'morph' (default), 'move', 'melt' or 'bend'.
morph / move / melt / bendThe knobs above, plus advanced (below).
dissolvetrue, 0..1, or raw DissolveOptions — melts imagery where surfaces meet. Orthogonal to effect.
x y scale transition delayComponent-driven position: the library animates element + liquid in one commit — pixel-perfect sync. transition is 'snappy' | 'smooth' | 'bouncy', { stiffness, damping, mass } or { duration, ease }.
observePlain-merge items animated by your code: the liquid follows the child's rendered rect. Implied by morph.shape, dissolve and effect="move".
radiusOverride the measured border-radius (number or [tl, tr, br, bl]).

Border-radius is measured from computed style; percentage radii, circles and pills just work.

Advanced (the pro escape hatch)

Every raw engine value stays reachable — the slim knobs map onto them, and advanced merges over the mapping:

<Liquid.Item
  morph={{
    shape: true,
    advanced: {
      evolve: { massStiffness: 320, cornerDuration: 820, roundness: 1 /* EvolveOptions */ },
      blobInset: 2,   // shrink the blob so an opaque photo covers its own liquid
      bridgeGrow: 8,  // liquid coat that swells out and necks into a neighbour
    },
  }}
  // dissolve takes its raw options directly — no `advanced` detour
  dissolve={{ warp: 26, mix: 0.7, gravity: 60, active: dragging /* DissolveOptions */ }}
/>

<Liquid.Item effect="move" move={{ advanced: { stiffness: 380, damping: 18 /* MoveOptions */ } }} />

Exported for typing and defaults: EvolveOptions / EVOLVE_DEFAULTS, MoveOptions / MOVE_DEFAULTS, DissolveOptions, Transition, presets. Highlights:

  • EvolveOptions — massStiffness/Damping, sizeStiffness/Damping, radiusStiffness/Damping, contentBlur (cross-blur px), roundness (0–1 droplet strength), anticipation/travel (ms the droplet lead ramps in / px it reaches), corner timeline cornerDuration/cornerDelay/cornerEase.
  • DissolveOptions — blur, warp, zone, range, mix (two-liquid erosion), gravity/taper (pointy directional flow), warpFreq, flowSpeed (churn), warpStyle, detail, active/releaseMs (wire active to your drag; release completes in exactly releaseMs).
  • MoveOptions — stiffness, damping, stretch, tail.

How it works (and why it's fast)

You cannot run a goo filter over real UI pixels — it destroys text and images, and WebKit's CSS url() filters on HTML are broken anyway. So the library uses two layers with one source of truth:

  • Silhouette layer (below): an SVG holding one plain blob per item, mirroring each item's geometry. The goo filter and the shadow chain run here. Filters on SVG content render identically in Chrome, Firefox and Safari.
  • Content layer (above): your real, interactive, unfiltered elements — crisp at all times. Give them transparent backgrounds; the liquid below is their surface.

Performance:

  • Component-driven motion compiles springs to CSS linear() easings (cached) — GPU-composited transitions, no per-frame JS.
  • Observed items share one per-group rAF loop that sleeps entirely (zero idle cost) after ~0.5s of stillness and wakes instantly via MutationObserver / transition events / ResizeObserver / pointer events.
  • The dissolve overlay renders only near contact and sleeps with the engine.

Caveats: the dissolve warps a snapshot of each image's src (rebuilt on resize), and writes mask-image onto <img> elements during contact — don't combine with your own masks there. morph.shape writes filter: blur() onto the element mid-morph — don't combine with your own filter there. Rotation isn't mirrored (v1).

Notes

  • Accessibility: the content layer is real DOM — focus rings, hit targets and ARIA are never filtered.
  • Reduced motion: component-driven transitions collapse to instant snaps under prefers-reduced-motion.
  • Theming: pass fill="var(--surface)" and let CSS variables switch light/dark.
  • Item backgrounds should be transparent — the blob is the surface. An opaque child (a full-bleed image in a circle) covers its own blob, which is exactly right for image chips.

Publishing

npm run typecheck && npm run build
npm publish        # from a logged-in npm account

dist/ ships ESM + CJS + types; sideEffects: false; React ≥18 peer dependency.

Credits

Filter architecture distilled from the PRO7 "Gooey plus menu" prototype; API philosophy inspired by Sileo.

MIT

源码参数参考

以下实现签名和类型从本地固定版本源码生成。保留可选属性、原生继承类型、联合分支和 never 限制;不会将其他设计系统的 API 混入。forwardRef 实现中的 ref 属于 React 引用,不是新增业务属性。此区域是原始类型参考,不是上游说明的全文翻译。

GooeyRoot({
    blur = 6,
    contrast = 18,
    fill = '#fff',
    shadow,
    filterPadding = 24,
    waviness = 0,
    wavinessFreq = 0.018,
    filter: customFilter,
    className,
    style,
    children,
    ...rest
  }, fwd: Ref<HTMLDivElement>)

LiquidItem(props: LiquidItemProps)
export interface GooeyProps extends HTMLAttributes<HTMLDivElement> {
  /** Goo blur sigma in px — how far apart pieces start bridging. Default 6. */
  blur?: number
  /** Alpha-contrast slope — how sharp the liquid edge is. Default 18. */
  contrast?: number
  /** Fill of the liquid surface. Any CSS color, `var()` welcome. Default '#fff'. */
  fill?: string
  /** `box-shadow` syntax; rendered on the MERGED silhouette. `inset` layers
   *  paint inside the liquid edge (inner rings / top highlights). */
  shadow?: string
  /** Extra filter-region slack in px for blobs travelling outside the group box. Default 24. */
  filterPadding?: number
  /** Max px the liquid boundary undulates — the silhouette (and its shadows)
   *  run through a gentle noise displacement, so edges read as fluid instead
   *  of geometric. 0 (default) keeps the calm edge. */
  waviness?: number
  /** Noise frequency of the undulation; lower = longer, lazier waves. */
  wavinessFreq?: number
  /** Escape hatch: raw SVG filter primitives that REPLACE the goo chain
   *  (`blur`, `contrast`, `waviness` and the SVG half of `shadow` are then
   *  yours to reproduce). The input is `SourceGraphic`; the last primitive's
   *  output is what paints. Inset and spread shadows normally read a
   *  binarised `shape` result — keep that name if you keep them. */
  filter?: string
}

export type LiquidEffect = 'morph' | 'move' | 'melt' | 'bend'

export interface BendTuning {
  /** Vertical bow strength, 0..1. Default 0.6. */
  vertical?: number
  /** Horizontal cap deformation, 0..1. Default 0.35. */
  horizontal?: number
  /** Raw MoveOptions escape hatch, merged last. */
  advanced?: MoveOptions
}

export interface MorphTuning {
  /** Liquid shape-change physics: the surface springs behind size changes,
   *  travels as a droplet and settles like jelly. Off by default — plain
   *  merge needs no engine. */
  shape?: boolean
  /** Speed multiplier for the shape physics. 1 = default, 2 = twice as fast. */
  speed?: number
  /** 0..1 — how much the shape physics overshoot and wobble. 0 = calm and
   *  critically damped, 1 = very springy. Default 0.5. */
  bounce?: number
  /** Max px your CONTENT cross-blurs by while the liquid is in motion,
   *  sharpening as the shape settles — the content half of the effect, not
   *  just the surface. Applies with `shape`. Default 7, `0` disables. */
  contentBlur?: number
  /** Full escape hatch: raw engine options, merged over the mapped values. */
  advanced?: {
    evolve?: EvolveOptions
    /** Shrink the blob by px per side so opaque content fully covers its own
     *  liquid (e.g. round photos). */
    blobInset?: number
    /** Px the blob swells back out near a neighbour — a visible liquid coat
     *  that necks into the other surface. */
    bridgeGrow?: number
  }
}

export interface MoveTuning {
  /** How tightly the liquid chases the element. 0 = heavy syrup lag,
   *  1 = near-instant. Default 0.5. */
  springiness?: number
  /** How much the surface overshoots and wobbles on arrival. Default 0.5. */
  wobble?: number
  /** Velocity stretch of the drop. 0 = rigid. Default 0.36. */
  stretch?: number
  /** Trailing droplet size. 0 disables the tail. Default 0.575. */
  trail?: number
  /** Full escape hatch: raw spring values, merged over the mapped values. */
  advanced?: MoveOptions
}

export interface LiquidItemProps {
  /** 'morph' (default) or 'move'. */
  effect?: LiquidEffect
  /** Tuning for effect="morph". */
  morph?: MorphTuning
  /** Tuning for effect="move". */
  move?: MoveTuning
  /** Tuning for effect="bend". */
  bend?: BendTuning
  /** Tuning for effect="melt": the image source (auto-detected from a child
   *  <img> when omitted) plus the melt's physics. */
  melt?: ImageMeltOptions & { src?: string }
  /** Melt this item's imagery into a touching neighbour at the contact point
   *  — a liquid warp, not a blur. Orthogonal to `effect`: it describes what
   *  your CONTENT does where two surfaces meet, not how the surface moves.
   *  `true` for the tuned look, `0..1` to scale it, or the raw
   *  `DissolveOptions` for full control (wire `active` to your drag). */
  dissolve?: boolean | number | DissolveOptions
  /** Component-driven position: the library animates both the element and its
   *  liquid in perfect sync. Omit x/y and animate the child yourself (CSS,
   *  Framer Motion, …) — the liquid follows automatically when the effect
   *  needs it, or with `observe` for plain merge. */
  x?: number
  y?: number
  scale?: number
  /** Spring preset/config or `{ duration, ease }` for x/y. Default 'smooth'. */
  transition?: Transition
  /** Transition delay in ms (stagger). */
  delay?: number
  /** Plain-merge items animated by YOUR code: makes the liquid follow the
   *  child's rendered rect. Implied by `morph.shape`, `dissolve` and
   *  effect="move". */
  observe?: boolean
  /** Override the measured border-radius for the liquid (px). */
  radius?: number | CornerRadii
  className?: string
  style?: CSSProperties
  children?: ReactNode
}

源码中的解构默认值

只列出直接写在参数或 props 解构中的默认表达式。条件表达式、运行时主题和其他计算值请结合源码;这里不是全部行为的默认值清单。

// GooeyRoot
blur = 6

// GooeyRoot
contrast = 18

// GooeyRoot
fill = '#fff'

// GooeyRoot
filterPadding = 24

// GooeyRoot
waviness = 0

// GooeyRoot
wavinessFreq = 0.018

// LiquidItem
effect = 'morph'

原生与外部类型

以下外部类型仍由 React 或资源自身依赖定义,没有手工展开为虚构属性。实际可用类型以安装版本的 TypeScript 检查为准。

HTMLDivElement
HTMLAttributes<HTMLDivElement>
Ref<HTMLDivElement>
EvolveOptions
MoveOptions
ImageMeltOptions
DissolveOptions
Transition
CornerRadii
CSSProperties
ReactNode