Visual Effects

Liquid Metal

A WebGL2 surface that flows like metal — chrome, gold, mercury or iridescent oil.

An ambient surface rather than a transition: a domain-warped fBm height field read as a normal, lit by a single key light with a Fresnel rim.

Every variant is the same shader with a different (low, high, tint) triple, so switching variant is a uniform write — never a recompile. The field flows continuously, eases toward the pointer, and can also be masked into text, where a gradient travels along the glyphs instead.

Usage

LIQUID

Chrome flow · move the pointer over the surface to push the field around

<template>
  <div class="flex w-full max-w-md flex-col gap-3">
    <MLiquidMetal class="h-48">
      <div class="grid h-full place-items-center">
        <span class="text-5xl font-extrabold tracking-tighter text-white">
          LIQUID
        </span>
      </div>
    </MLiquidMetal>
    <p class="text-xs text-muted">
      Chrome flow · move the pointer over the surface to push the field around
    </p>
  </div>
</template>

Examples

Liquid Metal

METAL
<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <div
      v-if="!maskText"
      class="h-48"
    >
      <MLiquidMetal
        :distortion="distortion"
        :variant="variant"
        class="h-full"
      >
        <div class="grid h-full place-items-center">
          <span class="text-5xl font-extrabold tracking-tighter text-white">
            METAL
          </span>
        </div>
      </MLiquidMetal>
    </div>
    <div
      v-else
      class="grid h-48 place-items-center rounded-2xl bg-(--ui-bg-muted)"
    >
      <MLiquidMetal
        :distortion="distortion"
        mask-text
        :variant="variant"
        class="text-5xl font-extrabold tracking-tighter"
      >
        METAL
      </MLiquidMetal>
    </div>

    <div class="flex flex-wrap items-center gap-2">
      <UButton
        v-for="name in variants"
        :key="name"
        size="sm"
        :label="name"
        color="neutral"
        :variant="variant === name ? 'solid' : 'outline'"
        @click="variant = name"
      />
    </div>

    <div class="flex items-center justify-between gap-4">
      <UButton
        size="sm"
        :label="maskText ? 'Box mode' : 'Mask text'"
        icon="i-lucide-type"
        variant="outline"
        @click="maskText = !maskText"
      />
      <label class="flex flex-1 items-center gap-3 text-xs text-muted">
        <span class="w-16">distortion</span>
        <USlider
          v-model="distortion"
          :min="0"
          :max="3"
          :step="0.1"
          class="flex-1"
        />
      </label>
    </div>
  </div>
</template>

<script setup lang="ts">
const variants = ['chrome', 'gold', 'mercury', 'oil'] as const

type Variant = typeof variants[number]

const variant = ref<Variant>('chrome')
const maskText = ref(false)
const distortion = ref(1)
</script>

Customize

METAL

Customize

Distortion1
Mask Text
Paused
Pointer Influence
Speed1
Variantchrome

Variants, masking and distortion

Four palettes — chrome (default), gold, mercury and oil (the oil sheen is an iridescent mix the other three do not have). With mask-text, the component renders its slot as background-clip: text over a linear gradient that sweeps the glyphs on a nine-second loop, shifted by the pointer — a conic gradient would read as a rotating disc behind the text rather than as light travelling across it.

METAL
<template>
  <div class="flex w-full max-w-md flex-col gap-4">
    <div
      v-if="!maskText"
      class="h-48"
    >
      <MLiquidMetal
        :distortion="distortion"
        :variant="variant"
        class="h-full"
      >
        <div class="grid h-full place-items-center">
          <span class="text-5xl font-extrabold tracking-tighter text-white">
            METAL
          </span>
        </div>
      </MLiquidMetal>
    </div>
    <div
      v-else
      class="grid h-48 place-items-center rounded-2xl bg-(--ui-bg-muted)"
    >
      <MLiquidMetal
        :distortion="distortion"
        mask-text
        :variant="variant"
        class="text-5xl font-extrabold tracking-tighter"
      >
        METAL
      </MLiquidMetal>
    </div>

    <div class="flex flex-wrap items-center gap-2">
      <UButton
        v-for="name in variants"
        :key="name"
        size="sm"
        :label="name"
        color="neutral"
        :variant="variant === name ? 'solid' : 'outline'"
        @click="variant = name"
      />
    </div>

    <div class="flex items-center justify-between gap-4">
      <UButton
        size="sm"
        :label="maskText ? 'Box mode' : 'Mask text'"
        icon="i-lucide-type"
        variant="outline"
        @click="maskText = !maskText"
      />
      <label class="flex flex-1 items-center gap-3 text-xs text-muted">
        <span class="w-16">distortion</span>
        <USlider
          v-model="distortion"
          :min="0"
          :max="3"
          :step="0.1"
          class="flex-1"
        />
      </label>
    </div>
  </div>
</template>

<script setup lang="ts">
const variants = ['chrome', 'gold', 'mercury', 'oil'] as const

type Variant = typeof variants[number]

const variant = ref<Variant>('chrome')
const maskText = ref(false)
const distortion = ref(1)
</script>

Props

Prop Type Default Description
variant'chrome' | 'gold' | 'mercury' | 'oil''chrome'Which palette the shader mixes between. A uniform write — one program serves all four, so a live variant control never forces a recompile.
distortionnumber1Strength of the domain warp, clamped to 0..3. The upper bound is where the warp field folds onto itself: past it the normals stop varying smoothly and the surface tears into visible seams.
speednumber1Multiplier on the flow clock. 0 freezes the field where it stands; values above 1 accelerate it. Frame deltas are capped, so a backgrounded tab resumes from where it paused instead of jumping forward.
pausedbooleanfalseStops the clock without tearing down the context. Resuming clears the accumulated gap, so the surface continues from the frame it paused on.
pointerInfluencebooleantrueWhen enabled the field eases toward the pointer (about 0.08 per frame — weighted, but still tracking) and pushes the warp in front of it.
maskTextbooleanfalseRenders the slot as masked text instead of layering it over the canvas: the glyphs become the surface, filled by a sweeping gradient along their own axis. The slot content must then be plain text.
classstring-Merged onto the root. In box mode the canvas sits at z-0 and the slot at z-10; with mask-text it styles the text itself.

Events

None — the surface is ambient. There is no rest to wait for.

Slots

Slot Props Description
default-Box mode: content layered over the flow. mask-text: the glyphs to fill.

Accessibility

The canvas is aria-hidden="true" and contributes nothing to the accessibility tree; masked text stays real text in the DOM, so it is still read and selected normally despite its transparent fill.

Under prefers-reduced-motion the flow does not run: the component paints the static CSS stand-in for the current variant — a conic sweep for box mode (the closest CSS primitive to a reflection off a curved surface; a linear gradient reads as a flat banner, not as metal) and a linear sweep for text.

Performance

  • One compiled WebGL2 program for all four variants; variant, distortion and pointer are uniforms.
  • The clock caps frame deltas at 1/20s, so returning to a backgrounded tab does not advance the flow by however long the tab was hidden.
  • The clock also stops while the element is scrolled out of view, while paused, and under reduced motion — each path resumes from the same frame rather than from a jump.
  • The shader requires WebGL2 (#version 300 es). On a WebGL1-only context it will not compile, and the component takes its static fallback instead — the correct outcome, not an error to surface.
Distortion past 2.5 is where most displays start showing the fold seams. The range goes to 3 so you can see the boundary yourself — stay at or below 2 for a surface that reads as liquid rather than as crumpled foil.
Copyright © 2026