Components/FusionSurface

FusionSurface

A rounded surface that grows buds from its edges and pinches them into drops.

VerifiedSince 0.6.0

Installation

EXAMPLE.BASH
pnpm add @talex-touch/tuffex
EXAMPLE.TYPESCRIPT
import { TxFusionSurface } from '@talex-touch/tuffex/fusion-surface'
import '@talex-touch/tuffex/fusion-surface/style.css'
import '@talex-touch/tuffex/base.css' // tokens + resets, once per app

Usage

Toolbar Tray

With a stable id, a new center slides the bud over; removing it from buds closes it in place.

Loading demo...

Split into a Drop

Once detach passes the break point, the neck snaps: the bud becomes a drop and the stub sinks back into the edge.

Loading demo...

Buds on Every Edge

edge picks the side a bud grows from; a bud pushed toward a corner is clamped onto the straight edge.

Loading demo...

Best Practices

  • Keep buds on one edge apart: each takes its width plus a fillet on each side.
  • Keep a bud's id stable while it moves; a new id closes the old bud and grows another.
  • To replay a split, close the bud (open: false, or drop it from buds) or use a new id; lowering detach only moves the drop back.
  • Give the root no background, border, or box-shadow, and never clip it (overflow: hidden, clip-path, contain: paint); buds take no layout space, so pad the parent.
  • Color the surface with var(--tx-…) tokens to follow the theme, and give the shadow to shadow, not to children.

API Reference

Props

PropTypeDefaultDescription
budsFusionSurfaceBud[][]Buds to grow (fields under Types); a removed bud closes before its content unmounts.
radiusnumber16Body corner radius, capped at half the shorter side; also each bud's default outer radius.
filletnumber12Largest radius of the concave fillet into the body; at most half the bud's current height.
breakAtnumber28detach at which the neck fully closes; it snaps at about 96% of it.
fillstringvar(--tx-bg-color-overlay)Surface fill color.
strokestringnoneOutline color; follows every bud, neck, and drop.
strokeWidthnumber1Outline width in px.
shadowstringvar(--tx-elevation-3)box-shadow syntax, drawn as drop-shadow() on the silhouette; 'none' removes it.
transition'snappy' | 'smooth' | 'bouncy' | SpringConfig'smooth'Spring for bud motion; no duration form, and velocity carries across retargets.
contentBlurnumber6Blur in px that bud content starts from as it opens; 0 keeps only the fade.

Events

EventParamsDescription
break(id: string)Fires on the frame a bud's neck snaps; once per split.
settle()Fires when every spring rests and the frame loop sleeps; not for changes that move nothing.

Slots

SlotPropsDescription
default-Body content: interactive DOM over the silhouette.
bud{ bud: FusionSurfaceBud }One bud's content layer (with data-bud); rides the bud or drop and is inert while closed.

Types

FusionSurfaceBud, one entry of buds:

FieldTypeDefaultDescription
idstring-Identity across updates. Required.
edge'top' | 'right' | 'bottom' | 'left''top'Edge it grows from; a new edge regrows it there instead of sliding around the corner.
openboolean | numbertrueOpen or closed, or a 0..1 amount open.
centernumbermiddle of the edgePosition along the edge in px, from the left or top; clamped.
widthnumber-Size along the edge in px; narrowed to fit the straight part. Required.
heightnumber-Outward size when fully open, in px. Required.
radiusnumberthe surface radiusOuter corner radius, capped by the bud's own size.
detachnumber0How far the bud is pulled away, in px; past the break point it becomes a drop.
driftnumber0Sideways offset of the pulled-away part in px: rightward on top/bottom, downward on left/right.
EXAMPLE.TS
import type {
  FusionSurfaceBud,
  FusionSurfaceEdge, // 'top' | 'right' | 'bottom' | 'left'
  FusionSurfaceEmits,
  FusionSurfaceProps,
  FusionSurfaceTransition, // 'snappy' | 'smooth' | 'bouncy' | SpringConfig
  TxFusionSurfaceInstance,
  // fusionSurfacePath(), see Geometry function
  FusionSurfaceBudShape,
  FusionSurfaceGeometry,
  FusionSurfaceGeometryInput,
  FusionSurfaceRect,
  FusionSurfaceSpan,
  FusionSurfaceSplit,
} from '@talex-touch/tuffex/fusion-surface'

CSS Variables

VariableDescription
--tx-fusion-surface-fillSilhouette fill, written by fill.
--tx-fusion-surface-strokeOutline color, written by stroke.
--tx-fusion-surface-stroke-widthOutline width, written by strokeWidth.
--tx-fusion-surface-filterThe silhouette's filter, written by shadow.
--tx-fusion-surface-progressHow open a bud is (0..1), written on its content layer each frame for bud slot content.

The first four are written only for props you pass, so an ancestor or theme can set them.

Geometry function

fusionSurfacePath() is the component's geometry as a pure function, with no Vue or DOM. Use it to draw buds, necks, and drops over a host that can't become a TxFusionSurface.

EXAMPLE.TS
import type { FusionSurfaceSplit } from '@talex-touch/tuffex/fusion-surface'
import {
  FUSION_SURFACE_BREAK_PINCH,
  fusionSurfacePath,
  fusionSurfacePinch,
  springSteps,
} from '@talex-touch/tuffex/fusion-surface'

const overlayPath = document.querySelector<SVGPathElement>('#composer-overlay path')!
let detach = 0
let velocity = 0
let split: FusionSurfaceSplit | null = null

function frame(dt: number) {
  // The component's spring, velocity kept across retargets.
  ;[detach, velocity] = springSteps(detach, velocity, 44, 'smooth', dt)
  const pinch = fusionSurfacePinch(detach, 28)
  // Latch the break as the component does (it also caps detach at the break point).
  if (!split && pinch >= FUSION_SURFACE_BREAK_PINCH)
    split = { center: 208, width: 168, height: 36, detach, drift: 0, remnant: 1, tail: 1 }
  // …then spring split.remnant and split.tail down to 0 ('snappy').

  const { d } = fusionSurfacePath({
    width: 320,
    height: 48,
    radius: 16,
    includeBody: false, // the host draws its own body
    buds: [{ id: 'message', center: 208, width: 168, height: 36, detach, pinch, split }],
  })
  overlayPath.setAttribute('d', d)
}

A path from includeBody: false closes inside the host. To stroke it, clip away the part inside the host and break the host's own border along spans.

Input

FusionSurfaceGeometryInput:

FieldTypeDefaultDescription
width / heightnumber-Body size in px; nothing is drawn unless both are positive.
radiusnumber16Body corner radius, capped at half the shorter side.
budsFusionSurfaceBudShape[][]The buds as they are on this frame.
includeBodybooleantruefalse outputs only the buds, necks, and drops.
baseOverlapnumber2With includeBody: false, how far (px) each attached shape reaches into the body to cover the host's border.

FusionSurfaceBudShape takes the other fields of FusionSurfaceBud, but describes the current frame rather than a target:

FieldTypeDefaultDescription
heightnumber-Current outward height; grow it from 0 to open, and under 0.5 px nothing is drawn. After a split, the drop's height before split.scale.
filletnumber12Largest concave fillet radius.
pinchnumber0How far the neck has narrowed (0..1); 1 closes it to a point.
splitFusionSurfaceSplit | nullnullSet once the neck snaps; pinch is ignored from then on.

FusionSurfaceSplit:

FieldTypeDefaultDescription
center / width / height / detach / driftnumber-The bud on the frame it snapped; moving the bud afterwards moves only the drop.
remnantnumber-Progress of the stub sinking into the body edge, from 1 down to 0.
tailnumber-Progress of the drop's pointed end rounding off, from 1 down to 0.
scalenumber1Scale of the drop about its own center; the component lowers it to 0 as a split bud closes.

Output

FusionSurfaceGeometry:

FieldTypeDescription
dstringClosed clockwise subpaths to 2 decimals: the body with attached buds merged in, then one per drop. Never contains NaN or Infinity.
spansFusionSurfaceSpan[]{ id, edge, from, to }: where each attached bud, or its stub, meets the body edge. Break a host's border here.
rectsFusionSurfaceRect[]{ id, edge, x, y, width, height }: each bud's current outer box, the drop's after a split; the component places bud content from it.

Helpers

  • fusionSurfacePinch(detach, breakAt): the component's neck curve; 0 for the first quarter of breakAt, then easing in to 1.
  • FUSION_SURFACE_BREAK_PINCH: 0.985, the pinch at which the component latches the split.
  • springSteps(position, velocity, target, config, dt): the component's spring integrator; dt is in seconds, and it returns [position, velocity].

Overview

  • The silhouette is an SVG inside the root at z-index: -1, pointer-events: none and aria-hidden, under the slot content.
  • A bud added to buds grows in place; a removed bud closes first, its content still mounted and inert.
  • open, center, width, height, detach, and drift are springs with their own velocity, so a new target bends the motion instead of restarting it.
  • A split is latched: lowering detach never rejoins it. Closing a split bud shrinks the drop and its content about the drop's center.
  • shadow skips inset and spread layers; a var() layer passes through whole and must hold one layer, like --tx-elevation-*.
  • Under reduced motion, tracked live, every spring lands in one frame; a pull past the break still fires break.

Technologies

  • One requestAnimationFrame loop per surface integrates the shared springs (liquid/src/spring.ts), recomputes one sharp SVG path, and writes bud layer styles directly; it sleeps at rest.
  • The attached bud follows the Dock on uiarc.dev, whose technique was observed and no code taken; the neck is this library's own profile model.
  • Source: packages/tuffex/packages/components/src/fusion-surface/.
查看源码
packages/tuffex/packages/components/src/fusion-surface/index.ts

Use cases

  • A toolbar or dock whose tools grow option trays, submenus, or tooltips out of the bar.
  • A panel that grows a bubble or badge from its edge and lets it go.
  • Sending a message: the composer pinches it off into the thread; over a host you draw yourself, use fusionSurfacePath().
ComponentFor
FusionTwo slots fused by a goo filter
LiquidFree pieces that merge like droplets
BorderBeamA glow along an element's border