Metal Fx
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
"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 · Repository · Report an issue
v2
v2 replaces the plasma shader with Paper 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.innerShadowprop — 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_OCCLUDERandCURSOR_LIGHTconfigs.
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
npm install metal-fx
Quick start
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
<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:
<MetalFx preset="chromatic" /> {/* Iridescent rainbow (default) */}
<MetalFx preset="silver" /> {/* Cool steel */}
<MetalFx preset="gold" /> {/* Warm gold */}
Theme
<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:
const appTheme = useAppTheme(); // 'dark' | 'light'
<MetalFx theme={appTheme}>...</MetalFx>
Strength
<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
<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:
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
requestAnimationFrameloop drives every instance. Per-frame work for one mount: agl.drawArraysplus N×drawImagecopies (one per visible instance). IntersectionObserverpauses per-instance copies when the host scrolls offscreen. When every instance is offscreen the GL render is skipped too.ResizeObservercallbacks 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):
// 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:
// 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:
<MetalFx borderRadius={20}>
<button>Upgrade to Pro</button>
</MetalFx>
License
MIT © Jakub Antalik. See LICENSE.
Native ports (v2)
The v2 effect is also available for the phone, in ports/:
- SwiftUI —
ports/ios/MetalFxKit(iOS 17+, Metal shader via SwiftUIcolorEffect/layerEffect).MetalFx,MetalText,MetalBadge,.metalReflection(of:), tilt-driven bend, glow, inner shadow, and a screen-edge liquid lens (.metalEdgeHalo()). Demo app inports/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 inports/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.
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;
})
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.
// 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.
HTMLDivElement
Omit<HTMLAttributes<HTMLDivElement>, 'children'>
HTMLAttributes<HTMLDivElement>
ReactNode
ReadonlyArray<MetalFxReflectionTarget>
RefObject<HTMLElement | null>
HTMLElement
MaskFn
CSSProperties