金属质感
Libraries.dev 的独立组件,请不要替换为 JGuang UI/Base UI 的同名 API。
安装
npm install metal-fx@2.0.11
安装包后使用它自己的导出组件,支持 React 18+。Canvas/WebGL 组件需要浏览器环境;提供静态降级,并尊重减少动画偏好。声音预览使用模拟信号,不会自动申请麦克风。
使用与验证
在左侧选择组件,在预览中操作它的状态;使用“查看代码”检查真实源码。安装后执行类型检查和构建,再验证键盘焦点、手机布局与深浅色主题。
来源
https://github.com/Jakubantalik/Libraries.dev/tree/b7f44b8744f4e09ff088d824719636a513110e2a MIT 许可证;不包括付费内容。下方提供上游英文参考内容,中文使用指南已适配;上游详细参考暂保留英文。
适用场景
为单个元素包裹原生金属环,或使用独立 MetalText 文字蒙版导出。
关键原生参数
| 参数 | 说明 |
|---|---|
preset | MetalFx 支持 chromatic、silver、gold,均为公开 npm 预设。 |
theme | auto 使用系统偏好,网页有手动开关时应传入应用主题。 |
paused | MetalFx 保持最后一帧着色器结果;MetalText 不暴露同一个暂停属性。 |
strength | 0–1 强度只控制透明效果,不停止着色器时钟;控制动画使用 paused 或卸载。 |
normalizeHostStyles | 默认为 true,会规范子元素外观;接入真实控件时检查边框和焦点轮廓。 |
交互与状态检查
只包裹一个可测量子元素,切换三种预设、暂停并检查焦点。低性能设备应测量 Canvas/WebGL 与文字蒙版开销,并检查静态降级。
业务接入边界
MetalText 有自己的 font/color/蒙版 API,不要把 MetalFx 参数静默套过去。特效不是支付或 Pro 订阅,本例未复制付费徽章素材。
示例文件与宿主配置
从源码面板复制 examples 下的组件文件和 shared.tsx,保留它们的相对位置;不是只复制原生上游实现。先安装固定版本 npm 包,在浏览器组件中使用它自己的导出。示例不需要 Tailwind 或新的主题 Provider,采用宿主 html 的语言和主题,并提供减少动画与图形不可用降级。原生实现参考仅供阅读,通过 npm 导入,不要求手工复制内部引擎。示例场景名称不是新增的原生组件属性。下方上游详细参考保留英文,不宣称全文中文化。
可运行用法
以下代码使用资源原生 API;不是把其他设计系统的同名组件替换进来。
"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>;
}
可用状态与示例
彩色金属、银色金属、金色金属、暂停动画、金属文字
组件中的状态与数据为演示用途,不包含后端接口。接入实际项目时请检查源码中的公开参数,再连接业务数据。
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.
源码参数参考
以下实现签名和类型从本地固定版本源码生成。保留可选属性、原生继承类型、联合分支和 never 限制;不会将其他设计系统的 API 混入。forwardRef 实现中的 ref 属于 React 引用,不是新增业务属性。此区域是原始类型参考,不是上游说明的全文翻译。
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 }
源码中的解构默认值
只列出直接写在参数或 props 解构中的默认表达式。条件表达式、运行时主题和其他计算值请结合源码;这里不是全部行为的默认值清单。
// 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
原生与外部类型
以下外部类型仍由 React 或资源自身依赖定义,没有手工展开为虚构属性。实际可用类型以安装版本的 TypeScript 检查为准。
HTMLDivElement
Omit<HTMLAttributes<HTMLDivElement>, 'children'>
HTMLAttributes<HTMLDivElement>
ReactNode
ReadonlyArray<MetalFxReflectionTarget>
RefObject<HTMLElement | null>
HTMLElement
MaskFn
CSSProperties