# Metal Fx

- Page: https://jiguang132.com/resources/libraries-dev/docs/metal-fx
- Resource: Libraries.dev (https://jiguang132.com/resources/libraries-dev.md)
- All guides: https://jiguang132.com/resources/libraries-dev/docs.md

Install: `npm install metal-fx@2.0.11`


## When to use

Wrap one element in a native metal ring, or use the separate MetalText text-mask export.

## Key native props

| Prop | Meaning |
|---|---|
| `preset` | MetalFx supports chromatic, silver and gold; these are public npm presets. |
| `theme` | auto uses OS preference, so pass the app's theme when users can toggle it manually. |
| `paused` | MetalFx holds its last shader frame. MetalText does not expose this same prop. |
| `strength` | Opacity 0–1 does not stop the shader clock. Use paused or unmount for animation control. |
| `normalizeHostStyles` | Defaults to true and normalizes child chrome. Review border/outline behavior on real accessible controls. |

## Interaction and state checks

Use a single measurable child, change all three presets, pause and test focus. Canvas/WebGL and text-mask work should be profiled on low-power devices; check a static fallback too.

## Integration boundaries

MetalText has its own font/color/mask API; do not silently transfer MetalFx props to it. These effects are not payments or a Pro subscription; no paid badge artwork is copied.

## Example files and host setup

Copy the component file and shared.tsx from examples in the source panel, keeping their relative locations; do not copy only the upstream implementation. Install the pinned npm package and use its own export in a browser component. Examples need no Tailwind or competing theme provider, read host html language/theme and provide reduced-motion/graphics fallbacks. Native implementation references are for reading; import via npm rather than copying an incomplete engine. Showcase labels are not new native props. Upstream detailed prose remains English; no full-translation claim is made.


## Runnable usage

```tsx
"use client";
import { useState } from "react";
import { MetalFx, MetalText, type MetalFxPreset } from "metal-fx";
import { DemoFrame, GraphicsGuard, control, demoTheme, t } from "./shared";
export default function Example({ variant = "Default" }: { variant?: string }) {
  const [preset, setPreset] = useState<MetalFxPreset>(variant === "Silver" ? "silver" : variant === "Gold" ? "gold" : "chromatic"), [paused, setPaused] = useState(variant === "Paused"), [strength, setStrength] = useState(1);
  if (variant === "Metal text") return <DemoFrame status={t("MetalText uses its own text-mask API, not MetalFx pause props.", "MetalText 使用自己的文字蒙版 API，不接受 MetalFx 的暂停属性。") }><GraphicsGuard kind="webgl2"><MetalText font="600 36px/1.2 system-ui" color={demoTheme() === "dark" ? "#ddd" : "#333"} theme={demoTheme()}>JGuang</MetalText></GraphicsGuard></DemoFrame>;
  return <DemoFrame status={`preset=${preset}, paused=${paused}, strength=${strength.toFixed(2)}`} controls={<>
    <label>{t("Preset", "预设")} <select style={control} aria-label={t("Metal preset", "金属预设")} value={preset} onChange={e => setPreset(e.target.value as MetalFxPreset)}>{["chromatic", "silver", "gold"].map(s => <option key={s}>{s}</option>)}</select></label>
    <label>{t("Strength", "强度")} <input aria-label={t("Metal strength", "金属强度")} type="range" min="0" max="1" step="0.05" value={strength} onChange={e => setStrength(Number(e.target.value))} /></label>
    <button type="button" style={control} aria-pressed={paused} onClick={() => setPaused(!paused)}>{paused ? t("Resume", "继续动画") : t("Pause", "暂停动画")}</button>
  </>}><GraphicsGuard kind="webgl2"><MetalFx preset={preset} paused={paused} strength={strength} theme={demoTheme()}><button type="button" style={{ ...control, padding: "12px 28px", borderRadius: 999 }}>{t("Native metal surface", "原生金属表面")}</button></MetalFx></GraphicsGuard></DemoFrame>;
}

```


## Preview states

Default, Silver, Gold, Paused, Metal text

Demo state and mock data do not provide a backend service. Inspect the component props before wiring real data.

# metal-fx

Animated WebGL "liquid metal" effect for React. Wrap a button, chip, or icon and it gets a real-time metal ring with optional proximity reflection on neighbouring elements.

[Live demo](https://metal.jakubantalik.com) · [Repository](https://github.com/Jakubantalik/metal-fx) · [Report an issue](https://github.com/Jakubantalik/metal-fx/issues)

## v2

v2 replaces the plasma shader with [Paper Shaders'](https://github.com/paper-design/shaders) `liquidMetal` (Apache-2.0, see `NOTICE`) and adds:

- `MetalText` — metal inside the glyphs of any text, with an inner-shadow rim.
- `MetalBadge` — the "New" pill: white fill, metal rim, clean core under the label.
- `useMetalBend(ref)` — cursor-driven liquid dent of the ring (spring physics, vector outline).
- `useMetalTextReflection(ref)` — a neighbouring text catches the metal on its letterforms.
- `innerShadow` prop — light rim along the ring's top inside edge.
- Cursor light — the ring lights the pointer (`setCursorSprite`, `setCursorLightConfig`); opt-in, macOS sprite required.
- Baked-sprite glow: ~10× cheaper than v1's SVG filters, same look.
- Live-tunable `GLOW`, `REFLECTION_OCCLUDER` and `CURSOR_LIGHT` configs.

Requires WebGL2 (`isMetalFxSupported()`); unsupported browsers render the plain child. Presets, variants and the `MetalFx` props from v1 are unchanged. To stay on the old engine: `npm install metal-fx@1`.

## Install

```bash
npm install metal-fx
```

## Quick start

```tsx
import { MetalFx } from 'metal-fx';

function App() {
  return (
    <MetalFx variant="button">
      <button className="upgrade-pill">Upgrade to Pro</button>
    </MetalFx>
  );
}
```

The component wraps a single child host element, measures it, and paints an animated metal ring on top. The child stays fully interactive — overlays sit above it with `pointer-events: none`.

## Variants

```tsx
<MetalFx variant="button">  {/* Pill silhouette, 1 px ring, scale 1.6 */}
  <button>Upgrade to Pro</button>
</MetalFx>

<MetalFx variant="circle">  {/* Compact circle, 2 px ring, scale 1.3 */}
  <button>↑</button>
</MetalFx>
```

## Presets

Three bundled palettes, each with a tuned dark and light mode block:

```tsx
<MetalFx preset="chromatic" />  {/* Iridescent rainbow (default) */}
<MetalFx preset="silver" />     {/* Cool steel */}
<MetalFx preset="gold" />       {/* Warm gold */}
```

## Theme

```tsx
<MetalFx theme="auto" />    {/* Follows prefers-color-scheme (default) */}
<MetalFx theme="dark" />    {/* Pin to dark backgrounds */}
<MetalFx theme="light" />   {/* Pin to light backgrounds */}
```

`auto` reads the OS / browser theme on mount and subscribes to live changes via `matchMedia('(prefers-color-scheme: dark)')`, so the metal frame switches over instantly when the user toggles their system theme. SSR-safe — the initial render falls back to `dark` and rehydrates to the resolved theme on the client.

If your app has its own theme toggle that doesn't follow the OS, drive `theme` from your app state instead:

```tsx
const appTheme = useAppTheme(); // 'dark' | 'light'
<MetalFx theme={appTheme}>...</MetalFx>
```

## Strength

```tsx
<MetalFx strength={0.7}>  {/* 70% effect intensity */}
  <button>Upgrade to Pro</button>
</MetalFx>
```

`strength` runs from `0` (invisible) to `1` (full, default). It scales the canvas and glow opacity without changing the underlying shader animation.

## Paused

```tsx
<MetalFx paused>
  <button>Upgrade to Pro</button>
</MetalFx>
```

Freezes the shader on its current frame. The metal silhouette stays visible.

## Proximity reflection (dark mode only)

Pass refs to neighbouring elements and they receive a soft, mirrored reflection of the metal ring:

```tsx
const sendRef = useRef<HTMLButtonElement>(null);
const chipRef = useRef<HTMLButtonElement>(null);

<>
  <button ref={chipRef}>Tools</button>
  <MetalFx variant="circle" reflectionTargets={[chipRef]}>
    <button ref={sendRef} aria-label="Send">↑</button>
  </MetalFx>
</>
```

Reflections are skipped automatically when the resolved theme is `light` — no DOM scanning, no per-frame work in light mode.

## Performance

- One shared WebGL context is reused across every mounted `<MetalFx>` on the page. The shader is compiled once.
- A single `requestAnimationFrame` loop drives every instance. Per-frame work for one mount: a `gl.drawArrays` plus N×`drawImage` copies (one per visible instance).
- `IntersectionObserver` pauses per-instance copies when the host scrolls offscreen. When every instance is offscreen the GL render is skipped too.
- `ResizeObserver` callbacks are debounced through RAF.
- The GL context, program, and buffer are released when the last `<MetalFx>` unmounts.

## Server-side rendering

The component renders a transparent placeholder during SSR and only mounts the WebGL pipeline after hydration on the client. No flash of broken effect, no SSR errors.

## Sizing

`MetalFx` does not force any dimensions onto the wrapped child — the wrapper sizes itself to whatever the child renders. Style your child the way you normally would (intrinsic content, CSS class, or inline style):

```tsx
// Pattern 1 (recommended): size the child.
<MetalFx variant="circle">
  <button style={{ width: 36, height: 36 }} aria-label="Send">↑</button>
</MetalFx>

<MetalFx>
  <button className="rounded-full px-6 h-10">Upgrade to Pro</button>
</MetalFx>
```

If you want a metal frame larger than the child (e.g. padding around an icon), size the wrapper instead and explicitly stretch the child to fill:

```tsx
// Pattern 2: size the wrapper, child fills.
<MetalFx style={{ width: 36, height: 36 }} variant="circle">
  <button style={{ width: '100%', height: '100%' }} aria-label="Send">↑</button>
</MetalFx>
```

Both patterns work; pick whichever fits your layout. The wrapper is `display: inline-flex` so it lays out inline like a button.

## Custom border radius

By default `MetalFx` reads the computed `border-radius` of the wrapped child each resize. Pass an explicit override when needed:

```tsx
<MetalFx borderRadius={20}>
  <button>Upgrade to Pro</button>
</MetalFx>
```

## License

MIT &copy; [Jakub Antalik](https://github.com/Jakubantalik). See [LICENSE](https://github.com/Jakubantalik/Libraries.dev/blob/b7f44b8744f4e09ff088d824719636a513110e2a/packages/metal-fx/LICENSE).

## Native ports (v2)

The v2 effect is also available for the phone, in `ports/`:

- **SwiftUI** — `ports/ios/MetalFxKit` (iOS 17+, Metal shader via SwiftUI
  `colorEffect` / `layerEffect`). `MetalFx`, `MetalText`, `MetalBadge`,
  `.metalReflection(of:)`, tilt-driven bend, glow, inner shadow, and a
  screen-edge liquid lens (`.metalEdgeHalo()`). Demo app in
  `ports/ios/MetalFxDemo` (`./run.sh`).
- **React Native** — `ports/react-native/metal-fx-native` (react-native-skia +
  Reanimated 4, everything on the UI thread). Same components; `<MetalReflection>`,
  `<MetalReflectionText>`, `<MetalEdgeHalo>`. Expo example in
  `ports/react-native/example`.

Both ports render the same material (Paper's `liquidMetal`, ported to MSL and
SkSL) with the same presets and tuning as the web; there is no cursor on a
phone, so the cursor light is web-only and the phone's tilt plays the pointer.


## Source-backed parameter reference

Implementation signatures and types are generated from the pinned local source. Optional props, native inherited types, union branches and never exclusions are preserved. A forwardRef implementation's ref is a React reference, not an additional business prop.


```ts
MetalFx({
    children,
    variant = 'button',
    preset = 'chromatic',
    theme = 'auto',
    strength = 1,
    glowGain = 1,
    paused = false,
    borderRadius,
    normalizeHostStyles = true,
    reflectionTargets,
    disableGlow = false,
    innerShadow,
    shaderScale,
    ringCssPx,
    scale = 1,
    mask,
    glowMode = 'mask',
    className,
    style,
    ...rest
  }, forwardedRef)

MetalText({
  children,
  font,
  color,
  strength = 1,
  theme,
  reflectionTargets,
  className,
  innerShadow = FIGMA_INNER_SHADOW,
  glow = false,
  glowGain = METAL_TEXT_DEFAULTS.glowGain,
  metalOpacity = METAL_TEXT_DEFAULTS.metalOpacity,
  shaderScale = METAL_TEXT_DEFAULTS.shaderScale,
}: {
  children: string;
  /** CSS `font` shorthand for the live span, e.g. `500 24px/1 Inter, sans-serif`. */
  font: string;
  /** Base text colour from the design; the metal composites over it. */
  color: string;
  strength?: number;
  theme?: MetalFxTheme;
  reflectionTargets?: ReadonlyArray<MetalFxReflectionTarget>;
  className?: string;
  /** Top-edge light rim inside the glyphs. Pass null to disable. */
  innerShadow?: TextInnerShadow | null;
  /** Halo on the glyphs. Off by default — the design has none. */
  glow?: boolean;
  /** Glow multiplier when `glow` is on. */
  glowGain?: number;
  /** How much metal shows over the base colour (0..1), multiplied with `strength`. */
  metalOpacity?: number;
  /** Zoom of the metal inside the glyphs. */
  shaderScale?: number;
})
```



```ts
export type MetalFxVariant = 'button' | 'circle';

export type MetalFxTheme = 'dark' | 'light' | 'auto';

export type MetalFxPreset = 'chromatic' | 'silver' | 'gold';

export type MetalFxReflectionTarget =
  | RefObject<HTMLElement | null>
  | { ref: RefObject<HTMLElement | null>; strength?: number };

export interface MetalFxProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
  /**
   * The single host element to wrap with the metal effect. Must be a single
   * React node (button, anchor, div, ...). MetalFx measures this element each
   * frame via `ResizeObserver` and paints its canvas + glow on top.
   */
  children: ReactNode;

  /**
   * Variant — controls the shader sampling scale + ring width.
   * - `button` (default): pill-style ring at 1 px wide, scale 1.6
   * - `circle`: compact circle with a 2 px ring, scale 1.3
   */
  variant?: MetalFxVariant;

  /**
   * Color preset. All three presets ship both dark and light mode tunings —
   * `theme` picks the right side at runtime.
   * @default 'chromatic'
   */
  preset?: MetalFxPreset;

  /**
   * Theme mode. `'auto'` (default) resolves via
   * `matchMedia('(prefers-color-scheme: dark)')` and switches live when the
   * OS theme changes. Pass `'dark'` or `'light'` to pin a specific mode.
   * @default 'auto'
   */
  theme?: MetalFxTheme;

  /**
   * Effect strength (0..1). Multiplies the shader bitmap opacity and the glow
   * SVG alpha. The shader continues to animate at full intensity at any value;
   * only the rendered alpha onto the host is scaled. Lighter values let the
   * underlying child surface show through more strongly.
   * @default 1
   */
  strength?: number;

  /**
   * Multiplier on the glow only (halo + catch-light), on top of `strength`.
   * Use it when the shader runs at low `strength` but the glow should still
   * read — e.g. metal text. Clamped to 0..1 after multiplying.
   * @default 1
   */
  glowGain?: number;

  /**
   * Pause the shader animation. The visible canvas keeps the last painted
   * frame so the metal silhouette stays on screen.
   * @default false
   */
  paused?: boolean;

  /**
   * Optional explicit border radius (CSS px). When omitted, MetalFx reads the
   * computed border-radius of the wrapped child each resize.
   */
  borderRadius?: number;

  /**
   * When true, MetalFx normalizes the host element's outer chrome (border /
   * outline / box-shadow) so user-provided component styles don't clash with
   * the metal ring. Inner fills, typography, and content remain untouched.
   * @default true
   */
  normalizeHostStyles?: boolean;

  /**
   * Neighbour elements that should receive a soft proximity reflection of the
   * metal effect. Reflections only render when the resolved theme is `dark` —
   * pass-through in light mode (no DOM scan, no per-frame work).
   *
   * Pass refs to the sibling DOM elements you want to receive the reflection
   * (chips next to a send button, search field next to an Upgrade pill, ...).
   *
   * A target that *contains* the wrapped element (its parent card, say)
   * receives the reflection on both the nearest vertical and horizontal
   * inner edges. Pair with `{ ref, strength }` to keep that subtle.
   */
  reflectionTargets?: ReadonlyArray<MetalFxReflectionTarget>;

  /**
   * Disable the wandering halo overlay. The shader ring still renders.
   * @default false
   */
  disableGlow?: boolean;

  /**
   * Light rim along the top inside edge of the ring — an inner shadow from
   * above, like the one on metal text. `true` uses the design defaults
   * (white 90 %, offset 1 px, blur 0.5 px); pass an object to tune.
   * @default undefined (off)
   */
  innerShadow?: boolean | { offsetY?: number; blur?: number; alpha?: number; color?: string };

  /**
   * Override the shader sampling scale. Larger values zoom into the shared
   * shader (visibly bigger pattern features); smaller values zoom out.
   * Defaults to the variant's baseline (1.6 for `'button'`, 1.3 for
   * `'circle'`) multiplied by `scale`.
   */
  shaderScale?: number;

  /**
   * Override the ring thickness in CSS pixels. Defaults to the variant's
   * baseline (1 for `'button'`, 2 for `'circle'`) multiplied by `scale`.
   */
  ringCssPx?: number;

  /**
   * Master scale multiplier for every absolute-pixel constant the engine
   * uses internally. Set this when you render the wrapped element at a
   * non-1× size (e.g. inside a CSS `zoom: 2` container, or when you've
   * doubled all your Tailwind sizes by hand). It scales:
   *   - shader sampling (so pattern features grow proportionally)
   *   - ring thickness on the canvas
   *   - glow SVG stroke widths, blur radii, fade-circle radius, and the
   *     small inset/outward offsets that position the catch-light
   *   - reflection canvas stroke band, border-highlight thickness, and the
   *     reference draw width baseline
   * @default 1
   */
  scale?: number;

  /**
   * Custom alpha mask painter. When set, the shader is kept only where the
   * mask paints (device px, origin at the wrapper's top-left) and the ring
   * punch is skipped — for metal-filled text or glyphs. Pair with
   * `disableGlow`; the glow assumes a ring perimeter.
   */
  mask?: MaskFn;

  /**
   * How the glow is placed when `mask` is set. `'mask'` (default) samples
   * points inside the mask and clips the halo to it — right for metal text.
   * `'ring'` keeps the rounded-rect perimeter behaviour — a halo along the
   * element's edge — for a full-fill mask like a badge.
   */
  glowMode?: 'mask' | 'ring';

  /**
   * Forwarded class name for the wrapper element.
   */
  className?: string;

  /**
   * Forwarded inline styles for the wrapper element.
   */
  style?: CSSProperties;
}

export interface TextInnerShadow { offsetY: number; blur: number; alpha: number }
```


### Destructuring defaults in source

Only defaults written directly in parameter/props destructuring are listed. Conditional expressions, runtime theme and computed values require source inspection; this is not an exhaustive behavior-default list.


```ts
// MetalFx
variant = 'button'

// MetalFx
preset = 'chromatic'

// MetalFx
theme = 'auto'

// MetalFx
strength = 1

// MetalFx
glowGain = 1

// MetalFx
paused = false

// MetalFx
normalizeHostStyles = true

// MetalFx
disableGlow = false

// MetalFx
scale = 1

// MetalFx
glowMode = 'mask'

// MetalText
strength = 1

// MetalText
innerShadow = FIGMA_INNER_SHADOW

// MetalText
glow = false

// MetalText
glowGain = METAL_TEXT_DEFAULTS.glowGain

// MetalText
metalOpacity = METAL_TEXT_DEFAULTS.metalOpacity

// MetalText
shaderScale = METAL_TEXT_DEFAULTS.shaderScale
```


### Native and external types

External types remain defined by React or native dependencies, not manually expanded into guessed props. The installed version's TypeScript definitions are authoritative.


```ts
HTMLDivElement
Omit<HTMLAttributes<HTMLDivElement>, 'children'>
HTMLAttributes<HTMLDivElement>
ReactNode
ReadonlyArray<MetalFxReflectionTarget>
RefObject<HTMLElement | null>
HTMLElement
MaskFn
CSSProperties
```

---

Index of all JGuang resources: https://jiguang132.com/llms.txt
