---
title: "FusionSurface"
description: "A rounded surface that grows buds from its edges and pinches them into drops."
category: Effects
status: beta
since: 0.6.0
tags: [fusion, surface, split, svg, spring]
syncStatus: reviewed
verified: true
---

## Installation

:::TuffCodeBlock{lang="bash"}
---
code: |
  pnpm add @talex-touch/tuffex
---
:::

:::TuffCodeBlock{lang="typescript"}
---
code: |
  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.
:::TuffDemoWrapper{demo="FusionSurfaceTrayDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { FusionSurfaceBud } from '@talex-touch/tuffex/fusion-surface'
  import { computed, ref } from 'vue'

  // Centre of each tray tool along the toolbar, in px
  const TRAYS = { draw: 106, shapes: 146 }
  const open = ref<keyof typeof TRAYS | null>(null)
  const shown = ref<keyof typeof TRAYS>('shapes')

  const buds = computed<FusionSurfaceBud[]>(() => open.value
    ? [{ id: 'tray', center: TRAYS[open.value], width: 146, height: 44, radius: 14 }]
    : [])

  function toggle(tool: keyof typeof TRAYS) {
    open.value = open.value === tool ? null : tool
    shown.value = tool
  }
  </script>

  <template>
    <TxFusionSurface
      :buds="buds"
      :radius="18"
      stroke="var(--tx-border-color-lighter)"
      shadow="var(--tx-elevation-4)"
    >
      <div class="tools">
        <button type="button" :aria-expanded="open === 'draw'" @click="toggle('draw')">Draw</button>
        <button type="button" :aria-expanded="open === 'shapes'" @click="toggle('shapes')">Shapes</button>
      </div>
      <template #bud>
        <!-- The options of `shown` -->
      </template>
    </TxFusionSurface>
  </template>
---
:::

### 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.
:::TuffDemoWrapper{demo="FusionSurfaceSplitDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { FusionSurfaceBud } from '@talex-touch/tuffex/fusion-surface'
  import { computed, ref } from 'vue'

  const sent = ref(false)
  const detach = ref(0)

  // 208: on a 320px composer, the furthest right a bud's fillet still lands on the straight edge
  const buds = computed<FusionSurfaceBud[]>(() => sent.value
    ? [{ id: 'message', center: 208, width: 168, height: 36, radius: 12, detach: detach.value }]
    : [])

  function send() {
    sent.value = true                            // the bud grows out of the top edge
    setTimeout(() => (detach.value = 22), 420)   // a neck stretches and pinches
    setTimeout(() => (detach.value = 44), 940)   // past breakAt: it snaps
  }
  </script>

  <template>
    <TxFusionSurface :buds="buds" stroke="var(--tx-border-color-lighter)">
      <span class="draft">Ship it tonight</span>
      <button type="button" aria-label="Send" @click="send" />
      <template #bud>
        <span class="bubble">Ship it tonight</span>
      </template>
    </TxFusionSurface>
  </template>
---
:::

### Buds on Every Edge
`edge` picks the side a bud grows from; a bud pushed toward a corner is clamped onto the straight edge.
:::TuffDemoWrapper{demo="FusionSurfaceEdgesDemo" code-lang="vue"}
---
code: |
  <script setup lang="ts">
  import type { FusionSurfaceBud } from '@talex-touch/tuffex/fusion-surface'
  import { computed, ref } from 'vue'

  const position = ref(50) // % along every edge
  const height = ref(32)

  // The body is 220 × 132
  const buds = computed<FusionSurfaceBud[]>(() => [
    { id: 'top', edge: 'top', center: position.value * 2.2, width: 64, height: height.value, radius: 12 },
    { id: 'right', edge: 'right', center: position.value * 1.32, width: 40, height: height.value, radius: 12 },
    { id: 'bottom', edge: 'bottom', center: position.value * 2.2, width: 64, height: height.value, radius: 12 },
    { id: 'left', edge: 'left', center: position.value * 1.32, width: 40, height: height.value, radius: 12 },
  ])
  </script>

  <template>
    <TxFusionSurface :buds="buds" :radius="18" style="width: 220px; height: 132px" />
    <TxSlider v-model="position" :min="0" :max="100" aria-label="Position along the edge" />
    <TxSlider v-model="height" :min="4" :max="40" aria-label="Bud height" />
  </template>
---
:::

### 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

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `buds` | `FusionSurfaceBud[]` | `[]` | Buds to grow (fields under Types); a removed bud closes before its content unmounts. |
| `radius` | `number` | `16` | Body corner radius, capped at half the shorter side; also each bud's default outer radius. |
| `fillet` | `number` | `12` | Largest radius of the concave fillet into the body; at most half the bud's current height. |
| `breakAt` | `number` | `28` | `detach` at which the neck fully closes; it snaps at about 96% of it. |
| `fill` | `string` | `var(--tx-bg-color-overlay)` | Surface fill color. |
| `stroke` | `string` | `none` | Outline color; follows every bud, neck, and drop. |
| `strokeWidth` | `number` | `1` | Outline width in px. |
| `shadow` | `string` | `var(--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. |
| `contentBlur` | `number` | `6` | Blur in px that bud content starts from as it opens; `0` keeps only the fade. |

### Events

| Event | Params | Description |
|-------|--------|-------------|
| `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

| Slot | Props | Description |
|-------|--------|-------------|
| `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`:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `id` | `string` | - | 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. |
| `open` | `boolean \| number` | `true` | Open or closed, or a 0..1 amount open. |
| `center` | `number` | middle of the edge | Position along the edge in px, from the left or top; clamped. |
| `width` | `number` | - | Size along the edge in px; narrowed to fit the straight part. Required. |
| `height` | `number` | - | Outward size when fully open, in px. Required. |
| `radius` | `number` | the surface `radius` | Outer corner radius, capped by the bud's own size. |
| `detach` | `number` | `0` | How far the bud is pulled away, in px; past the break point it becomes a drop. |
| `drift` | `number` | `0` | Sideways offset of the pulled-away part in px: rightward on top/bottom, downward on left/right. |

:::TuffCodeBlock{lang="ts"}
---
code: |
  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

| Variable | Description |
|----------|-------------|
| `--tx-fusion-surface-fill` | Silhouette fill, written by `fill`. |
| `--tx-fusion-surface-stroke` | Outline color, written by `stroke`. |
| `--tx-fusion-surface-stroke-width` | Outline width, written by `strokeWidth`. |
| `--tx-fusion-surface-filter` | The silhouette's `filter`, written by `shadow`. |
| `--tx-fusion-surface-progress` | How 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`.

:::TuffCodeBlock{lang="ts"}
---
code: |
  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`:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `width` / `height` | `number` | - | Body size in px; nothing is drawn unless both are positive. |
| `radius` | `number` | `16` | Body corner radius, capped at half the shorter side. |
| `buds` | `FusionSurfaceBudShape[]` | `[]` | The buds as they are on this frame. |
| `includeBody` | `boolean` | `true` | `false` outputs only the buds, necks, and drops. |
| `baseOverlap` | `number` | `2` | With `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:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `height` | `number` | - | 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`. |
| `fillet` | `number` | `12` | Largest concave fillet radius. |
| `pinch` | `number` | `0` | How far the neck has narrowed (0..1); 1 closes it to a point. |
| `split` | `FusionSurfaceSplit \| null` | `null` | Set once the neck snaps; `pinch` is ignored from then on. |

`FusionSurfaceSplit`:

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `center` / `width` / `height` / `detach` / `drift` | `number` | - | The bud on the frame it snapped; moving the bud afterwards moves only the drop. |
| `remnant` | `number` | - | Progress of the stub sinking into the body edge, from 1 down to 0. |
| `tail` | `number` | - | Progress of the drop's pointed end rounding off, from 1 down to 0. |
| `scale` | `number` | `1` | Scale of the drop about its own center; the component lowers it to `0` as a split bud closes. |

### Output

`FusionSurfaceGeometry`:

| Field | Type | Description |
|-------|------|-------------|
| `d` | `string` | Closed clockwise subpaths to 2 decimals: the body with attached buds merged in, then one per drop. Never contains `NaN` or `Infinity`. |
| `spans` | `FusionSurfaceSpan[]` | `{ id, edge, from, to }`: where each attached bud, or its stub, meets the body edge. Break a host's border here. |
| `rects` | `FusionSurfaceRect[]` | `{ 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](https://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/`.

<TuffDocSourceLink />

## 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()`.

## Related components

| Component | For |
|-----------|-----|
| [Fusion](/docs/dev/components/fusion) | Two slots fused by a goo filter |
| [Liquid](/docs/dev/components/liquid) | Free pieces that merge like droplets |
| [BorderBeam](/docs/dev/components/border-beam) | A glow along an element's border |
