metal-fx: a WebGL liquid-metal ring for React buttons
Animated WebGL liquid-metal effect for React buttons and UI components
At a glance
- What is it?
- metal-fx wraps a single React child in an animated WebGL metal frame with optional proximity reflections. It is a presentation-layer component, not an image upscaler, and the naming collision with Apple's MetalFX is worth clearing up before you install it.
- Who is it for?
- Adopt metal-fx if you are building a React 18+ interface and want one or two hero elements (an upgrade button, a send icon) to carry a metal frame without hand-writing shader code. Skip it if you need the effect on a page with dozens of animated mounts, if your design is light-mode only (proximity reflection is skipped when the resolved theme is light), or if you are not on React.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 10 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 1, 2026, and from our analysis. They are not legal advice.
Editorial analysis
What metal-fx actually is, and what it is not
The name collides with Apple's MetalFX upscaling technology, and a large share of search traffic for "metal fx" is about upscalers, Genshin Impact, Wuthering Waves, and off-road wheels. None of that is this project. Jakubantalik/metal-fx is a React component library, written in TypeScript, published on npm as metal-fx, and licensed MIT. Its job is narrow: you wrap a single child element, and the component measures that child and paints an animated WebGL metal ring on top of it.
The intended user is a frontend engineer on a React 18 or newer codebase who wants a decorative, real-time effect on a button, chip, or icon without writing GLSL or managing a WebGL lifecycle. The README's own example is an "Upgrade to Pro" button. That framing is honest about scope: this is a styling primitive for a small number of prominent controls, not a general-purpose rendering layer. If you are looking for image upscaling, temporal anti-aliasing, or anything that touches frame buffers, you are on the wrong package entirely.
How the shader pipeline and shared context work
The mechanism described in the README is a shared-context renderer. One WebGL context is reused across every mounted MetalFx on the page, and the shader is compiled once. A single requestAnimationFrame loop drives all instances. Per frame, the documented work for one mount is one gl.drawArrays call plus N drawImage copies, where N is the number of visible instances.
That architecture explains the component's other behaviours. IntersectionObserver pauses per-instance copies when a host scrolls offscreen, and when every instance is offscreen the GL render is skipped entirely. ResizeObserver callbacks are debounced through RAF, which matters because the wrapper reads the child's computed border-radius on each resize unless you pass an explicit borderRadius. The GL context, program, and buffer are released when the last MetalFx unmounts, so the context does not outlive the effect.
The child element stays fully interactive. Overlays sit above it with pointer-events: none, so the host button keeps receiving clicks and focus. The wrapper is display: inline-flex, which means it lays out inline like a button rather than introducing a block-level box into your layout. Sizing is deliberately not imposed: the wrapper sizes itself to whatever the child renders.
Installing metal-fx and wrapping your first button
Installation is a single npm command. The package declares react and react-dom at >=18.0.0 as peer dependencies, so your app supplies React; the package ships ESM and CJS builds from dist with type declarations.
npm install metal-fxThe quick start from the README wraps a button in the button variant, which the documentation describes as a pill silhouette with a 1 px ring and scale 1.6. The circle variant is a compact circle with a 2 px ring and scale 1.3, which suits an icon button.
import { MetalFx } from 'metal-fx';
function App() {
return (
<MetalFx variant="button">
<button className="upgrade-pill">Upgrade to Pro</button>
</MetalFx>
);
}What you should see is the child button rendered exactly as you styled it, with an animated metal ring painted over it. Three presets ship in the box: chromatic (the default, described as iridescent rainbow), silver, and gold. Theme defaults to auto, which reads the OS or browser preference on mount and subscribes to matchMedia('(prefers-color-scheme: dark)') for live changes. The README notes the component is 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 does not follow the OS, the documented approach is to drive the theme prop from your own state instead of relying on auto.
<MetalFx preset="gold" theme="dark" strength={0.7}>
<button>Upgrade to Pro</button>
</MetalFx>The strength prop runs from 0 (invisible) to 1 (full, and the default). It scales the canvas and glow opacity without changing the underlying shader animation, so it is a visual weight dial rather than a performance dial. The paused prop freezes the shader on its current frame while leaving the metal silhouette visible.
Proximity reflection and its dark-mode-only constraint
The most distinctive feature is reflectionTargets. You pass refs to neighbouring elements and they receive a soft, mirrored reflection of the metal ring. The README's example pairs a circle-variant send button with a separate "Tools" chip that catches the reflection.
The constraint is stated plainly: reflections are skipped automatically when the resolved theme is light. The README frames this as an optimization (no DOM scanning, no per-frame work in light mode) rather than a limitation, and that is a fair reading, but it is still a design decision you have to accept. If your product is light-mode only, the feature you were most interested in will never render. There is nothing in the README suggesting a light-mode reflection path is planned or configurable, so treat this as a fixed boundary rather than a toggle you have not found yet.
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>
</>Sizing, border radius, and the two layout patterns
Because the wrapper does not force dimensions on the child, you have to decide which element owns the size. The README gives two patterns. In the recommended one you size the child, using intrinsic content, a CSS class, or an inline style, and the wrapper follows. In the second you size the wrapper and explicitly stretch the child to fill it with width: '100%' and height: '100%'. The second pattern is what you want when the metal frame should be larger than the child, for example padding around an icon.
Border radius is read from the child's computed style on each resize by default. That is convenient when your child already has a rounded-full class or similar, because the ring will match without configuration. When the computed value is not what you want, or when reading it is undesirable, pass borderRadius as a number to override it. Note that this read happens on resize, so a child whose radius changes dynamically without a resize will not be picked up automatically.
<MetalFx style={{ width: 36, height: 36 }} variant="circle">
<button style={{ width: '100%', height: '100%' }} aria-label="Send">↑</button>
</MetalFx>Where metal-fx is the wrong tool
The shared-context design is efficient for a handful of mounts, but the per-frame cost is documented as one drawArrays plus N drawImage copies, where N is the number of visible instances. That is a linear term you cannot configure away. A page that mounts the effect on every row of a long list, or on a virtualized table where visibility churns constantly, is working against the architecture. The IntersectionObserver pausing helps when things are offscreen, but a screen full of simultaneously visible metal rings is exactly the case the README's own description implies is expensive.
There are other boundaries. The component wraps a single child host element, so it is not a container for arbitrary layouts. It is React-only: the peer dependencies are react and react-dom at >=18.0.0, and there is no documented framework-agnostic entry point. The README says nothing about what happens visually when a WebGL context cannot be created, which is a real scenario on locked-down browsers and some remote-desktop setups; the only related statement is that SSR renders a transparent placeholder and the WebGL pipeline mounts after hydration. Whether that placeholder persists as a graceful degradation or leaves an empty frame is not documented, and you should find out on your own target hardware rather than assume.
Compared with a CSS-only or canvas-only treatment
The obvious alternative is not another library but the technique metal-fx replaces: a CSS gradient or an animated background-image on the button itself, or a hand-rolled canvas element behind it. The difference is in what the browser has to do. A CSS gradient is composited by the browser's own pipeline, costs no JavaScript per frame, and degrades to a static color everywhere. It also cannot do a mirrored reflection of a neighbouring element, which is the specific thing reflectionTargets exists to produce. A hand-rolled canvas gives you the reflection but puts the context management, the RAF loop, the resize debouncing, and the offscreen pausing on you.
metal-fx's position is that it has already made those decisions: one shared context, one RAF loop, IntersectionObserver pausing, RAF-debounced resize, and context teardown on last unmount. If you only need a static metallic look, a CSS gradient is cheaper and has no React version constraint. If you need the animated ring and the neighbouring reflection, the component is doing work you would otherwise write yourself. If you need the effect outside React, this package does not offer a path, and you would be looking at a general WebGL or shader library instead.
Maintenance, licence, and what an upgrade costs you
The repository is not archived, and the last push was on 2026-09-07. The package version in package.json is 1.0.4, and no GitHub releases were retrieved, so version history lives in the npm package and the commit log rather than in release notes. The published surface is small: the exports map points at dist/index.es.js and dist/index.cjs.js with dist/index.d.ts types, and the files field ships only dist. That means the package you install is build output, not source, and there is no documented changelog to read before bumping.
Upgrade cost is therefore mostly about the peer range. react and react-dom are pinned at >=18.0.0 with no upper bound documented, so a future React major is neither promised nor excluded. The component's public API is a set of props (variant, preset, theme, strength, paused, reflectionTargets, borderRadius, style) plus the child, and the README does not describe a deprecation policy or a semver commitment beyond the 1.x version number. Treat a minor bump as something to eyeball against the demo before you take it. Licence is MIT, copyright Jakub Antalik, with the LICENSE file at the repository root. MIT is permissive and places few obligations on you, but this is not legal advice; if your organization has a policy on attribution or on vendored dependencies, check it against the LICENSE file directly.
Editorial conclusion
Adopt metal-fx if you are building a React 18+ interface and want one or two hero elements (an upgrade button, a send icon) to carry a metal frame without hand-writing shader code. Skip it if you need the effect on a page with dozens of animated mounts, if your design is light-mode only (proximity reflection is skipped when the resolved theme is light), or if you are not on React. Before shipping, verify two things yourself: that the wrapped child still receives clicks and focus as expected in your layout, and that the effect holds up on the low-end GPU or integrated graphics your users actually have. The README documents the mechanism and the constraints; it does not document visual fallback behaviour when a WebGL context cannot be created, so test that path on a machine where WebGL is blocked.
Frequently asked questions
how to use metal fx
Install metal-fx from npm, import the MetalFx component, and wrap a single child element such as a button. The wrapper measures the child and paints the animated metal ring over it, while the child stays interactive because overlays use pointer-events: none.
what is metal fx
In this context, metal-fx is a TypeScript React component library that renders an animated WebGL liquid-metal ring around a button, chip, or icon. It is unrelated to Apple's MetalFX upscaling technology, despite the similar name.
Is metal-fx like DLSS?
No. DLSS is an image upscaling technology, while metal-fx is a React component that draws a decorative WebGL metal frame around a UI element. The README describes no upscaling, resolution, or frame-generation behaviour.
Does metal-fx increase performance?
The README does not make a performance claim of that kind. It documents how the effect is kept cheap: one shared WebGL context, a single requestAnimationFrame loop, IntersectionObserver pausing for offscreen instances, and RAF-debounced resize callbacks.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/jakubantalik-metal-fx)