# Metal Button

> shadcn/ui Button wrapped in an animated liquid metal ring for React, in text and icon variants, for standout calls to action.

Source: https://www.cult-ui.com/docs/components/metal-button

## Example

```tsx title="metal-button-demo.tsx"
"use client"

import { useEffect, useId, useRef, useState, type ReactNode } from "react"
import { ArrowRight, Pause, Play, Sparkles, Wand2, Zap } from "lucide-react"
import type { MetalFxPreset } from "metal-fx"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  MetalButton,
  MetalIconButton,
} from "@/components/ui/metal-button"

const PRESET_ROW: { key: MetalFxPreset; label: string }[] = [
  { key: "chromatic", label: "Chromatic" },
  { key: "silver", label: "Silver" },
  { key: "gold", label: "Gold" },
]

const METAL_VARIANTS = ["button", "circle"] as const

const STRENGTH_ROW = [
  { value: 0.5, label: "50%" },
  { value: 0.75, label: "75%" },
  { value: 0.9, label: "90%" },
  { value: 1, label: "100%" },
] as const

function Section({
  title,
  description,
  children,
}: {
  title: string
  description?: string
  children: ReactNode
}) {
  return (
    <section className="space-y-4">
      <div className="space-y-1">
        <h3 className="text-foreground text-sm font-semibold tracking-tight">
          {title}
        </h3>
        {description ? (
          <p className="text-muted-foreground text-xs leading-relaxed text-pretty">
            {description}
          </p>
        ) : null}
      </div>
      {children}
    </section>
  )
}

export default function MetalButtonDemo() {
  const id = useId()
  const chipRef = useRef<HTMLButtonElement>(null)
  const [preset, setPreset] = useState<MetalFxPreset>("chromatic")
  const [metalPaused, setMetalPaused] = useState(false)
  const [respectsReducedMotion, setRespectsReducedMotion] = useState(false)

  const activePresetLabel =
    PRESET_ROW.find((row) => row.key === preset)?.label ?? "Chromatic"

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
    const sync = () => setRespectsReducedMotion(mq.matches)
    sync()
    mq.addEventListener("change", sync)
    return () => mq.removeEventListener("change", sync)
  }, [])

  const effectivePaused = metalPaused || respectsReducedMotion

  return (
    <div className="mx-auto w-full max-w-3xl space-y-10 px-4 py-8 md:px-6">
      <header className="space-y-2 text-center">
        <p className="text-muted-foreground text-[11px] font-medium tracking-[0.2em] uppercase">
          Liquid metal
        </p>
        <h2 className="text-foreground text-xl font-semibold tracking-tight md:text-2xl">
          Button + animated metal ring
        </h2>
        <p className="text-muted-foreground mx-auto max-w-lg text-sm leading-relaxed text-pretty">
          <span className="text-foreground/90">className</span> targets the
          shadcn <span className="text-foreground/90">Button</span>;{" "}
          <span className="text-foreground/90">metalFxClassName</span> styles
          the MetalFx wrapper. Use{" "}
          <span className="text-foreground/90">preset</span>,{" "}
          <span className="text-foreground/90">metalVariant</span>, and{" "}
          <span className="text-foreground/90">paused</span> to tune the effect.
        </p>
      </header>

      <Section
        description="Outline and secondary read well against the ring; default adds a stronger fill."
        title="Button variants"
      >
        <div className="flex flex-wrap items-center justify-center gap-3">
          <MetalButton preset={preset} type="button" variant="default">
            Continue
          </MetalButton>
          <MetalButton preset={preset} type="button" variant="outline">
            Outline
          </MetalButton>
          <MetalButton preset={preset} type="button" variant="secondary">
            Secondary
          </MetalButton>
          <MetalButton preset={preset} type="button" variant="ghost">
            Ghost
          </MetalButton>
        </div>
      </Section>

      <Section
        description="metal-fx shares one WebGL palette for the whole page — pick a preset, then watch the ring update on the preview below."
        title="Metal preset"
      >
        <div className="flex flex-col items-center gap-4">
          <fieldset
            aria-label="Metal preset"
            className="m-0 flex min-w-0 flex-wrap items-center justify-center gap-2 border-0 p-0"
          >
            {PRESET_ROW.map(({ key, label }) => (
              <Button
                aria-pressed={preset === key}
                key={key}
                onClick={() => setPreset(key)}
                size="sm"
                type="button"
                variant={preset === key ? "default" : "outline"}
              >
                {label}
              </Button>
            ))}
          </fieldset>
          <div className="flex w-full justify-center rounded-xl bg-neutral-950 px-6 py-8">
            <MetalButton
              preset={preset}
              theme="dark"
              type="button"
              variant="default"
            >
              {activePresetLabel}
            </MetalButton>
          </div>
        </div>
      </Section>

      <Section
        description="button is a pill ring; circle uses a thicker ring suited to compact controls."
        title="Metal variant"
      >
        <div className="flex flex-wrap items-center justify-center gap-3">
          {METAL_VARIANTS.map((variant) => (
            <MetalButton
              key={variant}
              metalVariant={variant}
              preset={preset}
              type="button"
              variant="outline"
            >
              <span className="font-mono text-xs">{variant}</span>
            </MetalButton>
          ))}
        </div>
      </Section>

      <Section
        description="Strength scales shader opacity and glow; the animation keeps running underneath."
        title="Strength"
      >
        <div className="flex flex-wrap items-center justify-center gap-3">
          {STRENGTH_ROW.map(({ value, label }) => (
            <MetalButton
              key={label}
              preset={preset}
              strength={value}
              type="button"
              variant="outline"
            >
              <span className="font-mono text-xs">{label}</span>
            </MetalButton>
          ))}
        </div>
      </Section>

      <Section
        description="Icon buttons default to icon sizing and circle variant. The Wand icon disables the halo glow."
        title="Icon buttons"
      >
        <div className="flex flex-wrap items-center justify-center gap-3">
          <MetalIconButton
            aria-label="Sparkles"
            preset={preset}
            title="Sparkles"
            type="button"
            variant="outline"
          >
            <Sparkles aria-hidden className="size-3.5" />
          </MetalIconButton>
          <MetalIconButton
            aria-label="Zap"
            preset={preset}
            title="Zap"
            type="button"
            variant="secondary"
          >
            <Zap aria-hidden className="size-3.5" />
          </MetalIconButton>
          <MetalIconButton
            aria-label="Wand"
            disableGlow
            preset={preset}
            title="Wand"
            type="button"
            variant="outline"
          >
            <Wand2 aria-hidden className="size-3.5" />
          </MetalIconButton>
        </div>
      </Section>

      <Section
        description="Toggle the shader without hiding the button. Respects prefers-reduced-motion. Dark-mode reflections hit the chip ref."
        title="Interactive"
      >
        <div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
          <button
            className="border-border bg-muted/50 text-muted-foreground rounded-full border px-3 py-1.5 text-xs"
            ref={chipRef}
            type="button"
          >
            Tools
          </button>
          <MetalButton
            className="gap-2 pr-5 pl-6"
            paused={effectivePaused}
            preset={preset}
            reflectionTargets={[chipRef]}
            type="button"
            variant="outline"
          >
            Get started
            <ArrowRight aria-hidden className="size-4 opacity-80" />
          </MetalButton>
          <MetalIconButton
            aria-label={effectivePaused ? "Play metal" : "Pause metal"}
            aria-pressed={!metalPaused}
            onClick={() => setMetalPaused((v) => !v)}
            paused={effectivePaused}
            preset={preset}
            title={effectivePaused ? "Play metal" : "Pause metal"}
            type="button"
            variant="secondary"
          >
            {effectivePaused ? (
              <Play aria-hidden className="size-3.5" />
            ) : (
              <Pause aria-hidden className="size-3.5" />
            )}
          </MetalIconButton>
        </div>
        <p
          className={cn(
            "text-center text-xs",
            respectsReducedMotion
              ? "text-amber-600 dark:text-amber-400"
              : "text-muted-foreground"
          )}
          id={`${id}-hint`}
        >
          {respectsReducedMotion
            ? "Reduced motion is on — metal animation stays paused."
            : "Tip: pause freezes the ring on the last frame; the button stays clickable."}
        </p>
      </Section>
    </div>
  )
}
```

Metal Button is a React button component for shadcn/ui built on the metal-fx library. Use it for a standout primary action on landing pages, hero sections, or product launch pages.

## Installation

### CLI

```bash
npx shadcn@latest add @cult-ui/metal-button
```

### Manual

**Install the [metal-fx](https://www.npmjs.com/package/metal-fx) package.**

```bash
pnpm add metal-fx
```

**Add the [shadcn/ui Button](https://ui.shadcn.com/docs/components/button) if
  you do not already have it.**

**Copy and paste the following code into your project.**

```tsx title="metal-button.tsx"
"use client"

/**
 * `metal-fx` around `Button` — liquid metal ring for controls.
 * `className` styles the button; `metalFxClassName` styles the MetalFx wrapper.
 *
 * With `normalizeHostStyles` (default), variant fills live on the MetalFx wrapper
 * and the button stays transparent so the shader ring stays visible. Pass
 * `normalizeHostStyles={false}` to keep all shadcn chrome on the button (filled
 * variants will cover most of the metal).
 */
import type { ComponentProps, CSSProperties } from "react"
import { forwardRef } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { MetalFx, type MetalFxProps, type MetalFxVariant } from "metal-fx"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"

const metalSurfaceVariants = cva("transition-colors", {
  variants: {
    variant: {
      default: "bg-primary! text-primary-foreground! hover:bg-primary/80!",
      outline:
        "bg-background! text-foreground! hover:bg-input/50! dark:bg-input/30!",
      secondary:
        "bg-secondary! text-secondary-foreground! hover:bg-secondary/80!",
      ghost:
        "text-foreground! hover:bg-muted/50! dark:hover:bg-muted/50! bg-transparent!",
      destructive:
        "bg-destructive/10! text-destructive! hover:bg-destructive/20! dark:bg-destructive/20! dark:hover:bg-destructive/30!",
      link: "text-primary! bg-transparent!",
    },
  },
  defaultVariants: {
    variant: "default",
  },
})

/** Strip outer chrome on the host; MetalFx punches the ring around the interior. */
const metalHostChromeReset =
  "border-0! bg-transparent! shadow-none! hover:bg-transparent! aria-expanded:bg-transparent!"

/** Keep a stable edge above the animated shader so bright frames cannot erase it. */
const metalStableEdge =
  "relative isolate before:pointer-events-none before:absolute before:inset-0 before:z-10 before:rounded-[inherit] before:ring-1 before:ring-border/70 before:ring-inset dark:before:ring-border/80"

type MetalSurfaceVariant = NonNullable<
  VariantProps<typeof metalSurfaceVariants>["variant"]
>

type MetalShellProps = Pick<
  MetalFxProps,
  | "preset"
  | "theme"
  | "strength"
  | "paused"
  | "borderRadius"
  | "disableGlow"
  | "reflectionTargets"
  | "shaderScale"
  | "ringCssPx"
  | "scale"
  | "normalizeHostStyles"
> & {
  metalVariant?: MetalFxVariant
  metalFxClassName?: string
  metalFxStyle?: CSSProperties
}

export type MetalButtonProps = ComponentProps<typeof Button> & MetalShellProps

export type MetalIconButtonProps = MetalButtonProps

export const MetalButton = forwardRef<HTMLDivElement, MetalButtonProps>(
  function MetalButton(
    {
      metalVariant = "button",
      metalFxClassName,
      metalFxStyle,
      preset = "chromatic",
      theme = "auto",
      strength = 0.9,
      paused,
      borderRadius,
      disableGlow,
      reflectionTargets,
      shaderScale,
      ringCssPx,
      scale,
      normalizeHostStyles = true,
      variant = "default",
      className,
      ...buttonProps
    },
    ref
  ) {
    const surfaceVariant = variant as MetalSurfaceVariant

    return (
      <MetalFx
        borderRadius={borderRadius}
        className={cn(
          "inline-flex w-fit min-w-0 flex-col items-stretch overflow-visible! leading-none",
          metalStableEdge,
          normalizeHostStyles &&
            metalSurfaceVariants({ variant: surfaceVariant }),
          metalFxClassName
        )}
        disableGlow={disableGlow}
        normalizeHostStyles={normalizeHostStyles}
        paused={paused}
        preset={preset}
        ref={ref}
        reflectionTargets={reflectionTargets}
        ringCssPx={ringCssPx}
        scale={scale}
        shaderScale={shaderScale}
        strength={strength}
        style={metalFxStyle}
        theme={theme}
        variant={metalVariant}
      >
        <Button
          className={cn(normalizeHostStyles && metalHostChromeReset, className)}
          variant={variant}
          {...buttonProps}
        />
      </MetalFx>
    )
  }
)

MetalButton.displayName = "MetalButton"

export const MetalIconButton = forwardRef<HTMLDivElement, MetalIconButtonProps>(
  function MetalIconButton(
    { size = "icon-sm", metalVariant = "circle", className, ...props },
    ref
  ) {
    return (
      <MetalButton
        className={cn(
          "leading-none! [&_svg]:block [&_svg]:shrink-0",
          className
        )}
        metalVariant={metalVariant}
        ref={ref}
        size={size}
        {...props}
      />
    )
  }
)

MetalIconButton.displayName = "MetalIconButton"
```

**Update the import paths to match your project setup.**

## Usage

`MetalButton` wraps the standard shadcn `Button` with [`MetalFx`](https://www.npmjs.com/package/metal-fx) from the **metal-fx** package. All visible instances on a page share one WebGL renderer, so multiple buttons stay efficient.

- **`className`** — styles the inner `Button` (padding, gap, typography).
- **`metalFxClassName`** / **`metalFxStyle`** — styles the MetalFx wrapper (surface fill when `normalizeHostStyles` is on).
- **`MetalIconButton`** — same API with `size="icon-sm"` and `metalVariant="circle"` by default.

```tsx
import { MetalButton, MetalIconButton } from "@/components/ui/metal-button";
import { Sparkles } from "lucide-react";
```

```tsx
<MetalButton type="button" variant="outline" preset="chromatic">
  Continue
</MetalButton>

<MetalIconButton
  type="button"
  variant="outline"
  aria-label="Sparkles"
  preset="gold"
>
  <Sparkles className="size-3.5" />
</MetalIconButton>
```

### Presets and theme

Pick a bundled palette with **`preset`**: `"chromatic"` (default), `"silver"`, or `"gold"`. Each preset includes dark and light tunings; **`theme`** (`"auto"` | `"dark"` | `"light"`) selects which side to use. Because metal-fx shares one palette per page, changing `preset` on any instance updates every visible ring.

```tsx
<MetalButton preset="gold" theme="dark" variant="default">
  Upgrade
</MetalButton>
```

### Ring shape and intensity

- **`metalVariant`** — `"button"` (pill ring, default) or `"circle"` (thicker ring for compact controls).
- **`strength`** — `0`–`1`; scales shader opacity and glow while the animation keeps running (default `0.9`).
- **`paused`** — freezes the ring on the last frame; the button stays fully clickable.

```tsx
<MetalButton
  metalVariant="circle"
  strength={0.75}
  paused={isPaused}
  variant="outline"
>
  Settings
</MetalButton>
```

### Styling split: button vs wrapper

With **`normalizeHostStyles`** (default `true`), variant fills and hover colors apply on the MetalFx wrapper so the inner button stays transparent and the liquid ring stays visible. Pass **`normalizeHostStyles={false}`** if you want full shadcn chrome on the button itself (filled variants will cover most of the metal).

```tsx
<MetalButton
  className="gap-2 pl-6 pr-5"
  metalFxClassName="shadow-sm"
  variant="outline"
>
  Get started
</MetalButton>
```

### Reflections and glow

In **dark** theme, pass **`reflectionTargets`** — an array of refs to sibling elements — for a soft proximity reflection on nearby chips or labels. Use **`disableGlow`** to turn off the wandering halo while keeping the shader ring.

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

<>
  <button ref={chipRef} type="button" className="...">
    Tools
  </button>
  <MetalButton
    reflectionTargets={[chipRef]}
    variant="outline"
  >
    Send
  </MetalButton>
</>
```

## API Reference

### Exports

| Export | Description |
| --- | --- |
| `MetalButton` | Text button with liquid metal ring |
| `MetalIconButton` | Icon-sized control; defaults to `size="icon-sm"` and `metalVariant="circle"` |
| `MetalButtonProps` | Props for `MetalButton` |
| `MetalIconButtonProps` | Alias of `MetalButtonProps` |

`MetalButton` accepts all [Button](https://ui.shadcn.com/docs/components/button) props plus MetalFx shell props below. The ref attaches to the MetalFx wrapper (`HTMLDivElement`).

### MetalFx props (forwarded)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `preset` | `"chromatic"` \| `"silver"` \| `"gold"` | `"chromatic"` | Bundled shader palette (shared page-wide) |
| `theme` | `"auto"` \| `"dark"` \| `"light"` | `"auto"` | Resolves preset tuning from OS or pins a mode |
| `metalVariant` | `"button"` \| `"circle"` | `"button"` | Ring shape and thickness baseline |
| `strength` | `number` | `0.9` | Effect opacity and glow (`0`–`1`) |
| `paused` | `boolean` | `false` | Freeze ring on last frame |
| `borderRadius` | `number` | — | Override computed child radius (CSS px) |
| `normalizeHostStyles` | `boolean` | `true` | Move variant fill to wrapper; strip button chrome |
| `disableGlow` | `boolean` | `false` | Disable halo; ring still renders |
| `reflectionTargets` | `RefObject<HTMLElement \| null>[]` | — | Dark-mode proximity reflections on siblings |
| `shaderScale` | `number` | variant default | Zoom into shared shader pattern |
| `ringCssPx` | `number` | variant default | Ring thickness in CSS pixels |
| `scale` | `number` | `1` | Master multiplier for ring, glow, and reflections |
| `metalFxClassName` | `string` | — | Class names on the MetalFx wrapper |
| `metalFxStyle` | `CSSProperties` | — | Inline styles on the MetalFx wrapper |

See the [MetalFx documentation](https://metal.jakubantalik.com/) for shader behavior, performance notes, and advanced tuning.

## Accessibility

- Prefer **`MetalIconButton`** with a visible **`aria-label`** (and optional `title`) for icon-only actions.
- Respect **`prefers-reduced-motion`**: pause the effect when users request reduced motion so the ring does not animate unnecessarily.
- The inner control remains a native **`button`** (or your polymorphic choice via Button props); keyboard activation works as usual.

## Performance

- One shared WebGL canvas drives every `MetalFx` instance on the page — prefer a single preset per view when possible.
- Reflection scanning runs only in dark mode and only for refs you pass in `reflectionTargets`.
- Use **`paused`** on off-screen or inactive controls to avoid painting frames users cannot see.

## Credits

The liquid metal shader and **metal-fx** React wrapper are by **Jakub Antalík**. Documentation, live demos, and the underlying effect are at [metal.jakubantalik.com](https://metal.jakubantalik.com/).
