Visual Effects

Emboss Surface

A material surface whose relief is built from stacked shadows or a lit SVG filter, with an optional pointer-tracked light angle.

Turns any element into a lit material surface. Four variants — emboss, deboss, plaster and metal-stamp — each describe a different relief recipe: two shadow layers for a raised surface, the same pair inset for a pressed one, a wider pair plus an SVG specular filter for plaster, and a hard zero-blur pair with an SVG drop-shadow for a stamped plate.

A stack of box-shadows can only darken, which is why plaster is different: it blurs the element's own alpha channel into a height field, lights that field with feSpecularLighting, and clips the result back to the original silhouette. That is what actually produces a lit surface rather than a shaded one.

Usage

Wrap content in the component and pick a variant. The rendered element takes its background from color and its size from your own classes, so give it a shape and padding. Note that the default color is the page background itself: pass a colour of your own, or the surface blends straight into the page and its relief reads only from the shadows. Add interactive when you want the light to follow the pointer — it is gated to hover-capable pointers and reduced motion automatically.

EmbossRaised surface
DebossPressed surface
PlasterLit SVG relief
Metal stampHard zero-blur edges

Move the pointer across this surface

<template>
  <div class="flex w-full max-w-2xl flex-col gap-4">
    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
      <MEmbossSurface
        v-for="entry in variants"
        :key="entry.value"
        :variant="entry.value"
        :color="entry.color"
        :softness="entry.softness"
        class="flex h-36 flex-col items-center justify-center gap-1 rounded-2xl border border-default p-6"
      >
        <span
          class="text-lg font-semibold"
          :class="entry.textClass"
        >
          {{ entry.label }}
        </span>
        <span
          class="text-xs opacity-70"
          :class="entry.textClass"
        >
          {{ entry.hint }}
        </span>
      </MEmbossSurface>
    </div>

    <MEmbossSurface
      variant="plaster"
      interactive
      color="#ded9d2"
      :softness="0.8"
      :depth="3"
      class="flex h-32 items-center justify-center rounded-2xl border border-default"
    >
      <p class="text-sm font-medium text-stone-800">
        Move the pointer across this surface
      </p>
    </MEmbossSurface>
  </div>
</template>

<script setup lang="ts">
const variants = [
  { value: 'emboss', label: 'Emboss', hint: 'Raised surface', color: '#52525b', softness: 0.5, textClass: 'text-zinc-100' },
  { value: 'deboss', label: 'Deboss', hint: 'Pressed surface', color: '#52525b', softness: 0.5, textClass: 'text-zinc-100' },
  { value: 'plaster', label: 'Plaster', hint: 'Lit SVG relief', color: '#d6d3d1', softness: 0.8, textClass: 'text-stone-800' },
  { value: 'metal-stamp', label: 'Metal stamp', hint: 'Hard zero-blur edges', color: '#71717a', softness: 0.35, textClass: 'text-zinc-50' },
] as const
</script>

Customize

Every prop is live — switch the material, drag the depth, softness and light angle, and toggle pointer tracking. variant and as are dropdowns; the rest start at the component's own defaults.

Emboss Surface

Customize

Asdiv
Color
Depth2
Interactive
Light Angle135
Softness0.8
VariantPlaster

Variants

VariantReliefBlurBuilt from
emboss (default)Raised, highlight top-left / shade bottom-rightSoft (softness × 2.4)Two box-shadow layers
debossPressed — same geometry, inset and invertedSoftTwo inset box-shadow layers
plasterPressed, wide and shallowWidest (× 1.8)Two inset layers + an SVG feSpecularLighting filter
metal-stampRaised, hard zero-blur edgesNone (0px)Two hard layers + two drop-shadow layers

plaster is the only variant that mounts an SVG filter, and the only one whose filter is an url(#…) rather than a drop-shadow. metal-stamp is the only other variant that touches filter, and the only one with a zero-blur layer.

Props

Prop Type Default Description
variant'emboss' | 'deboss' | 'plaster' | 'metal-stamp''emboss'The material recipe. Everything else — inset vs. outset, blur scale, highlight and shade mix percentages, hard edges, specular filter — is read from this one value.
colorstring'var(--ui-bg)'Base material colour, applied as the element's backgroundColor. The highlight and shadow tints are derived from it with color-mix() in oklab, so any CSS colour works. Note the default is var(--ui-bg), not shadcn's var(--background) — that variable does not exist under Nuxt UI, and an undefined custom property inside color-mix() invalidates the whole colour, which silently drops the entire box-shadow to none. A surface with no relief and no error.
depthnumber2Relief depth in pixels. Sets the shadow offsets and also scales the blur (depth × softness × 2.4 × variant blurScale, floored at 0.25). It is also the maximum pointer travel in interactive mode.
softnessnumber0.5Shadow diffusion from 0 (hard) to 1 (soft). Multiplies the blur; has no effect on metal-stamp, whose hard layers are explicitly 0px.
lightAnglenumber135Direction the light comes from, in degrees. 0 is right, 90 is up, so the default 135 is up-left. Split into --es-dx / --es-dy custom properties and recomputed as trig whenever it changes.
interactivebooleanfalseLet the light angle follow the pointer. Requires interactive, reduced-motion to be off, and a (hover: hover) and (pointer: fine) device — the pointer handlers are only attached when all three hold, and are undefined otherwise. The angle resets to lightAngle on pointerleave.
asstring'div'Element or component rendered as the material surface. Use as="button" or as="a" to make the surface itself interactive; the surface ref follows whatever is rendered.
classstring''Merged onto the surface after text-default. This is where padding, rounding and layout classes go — the component sets only colour, shadow, filter and text-shadow inline.

Slots

Slot Props Description
default-The surface's content. It sits above the background and below the text-shadow, so light text on a dark material stays legible.

UI

This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.embossSurface in app.config.ts.

Prop Type Default Description
uiobject-Per-slot classes merged over the theme defaults.
Slot Props Description
root-text-default
filter-pointer-events-none absolute h-0 w-0

Accessibility

The component renders no ARIA roles or attributes of its own and adds nothing to the accessibility tree — it is a box-shadow, a background colour and, for plaster, an aria-hidden="true" SVG whose <title> is decorative. Its only semantic contribution is as: setting as="button" gives you a real button, and setting as="div" leaves whatever you put inside to carry the semantics.

Interactive light tracking is pointer-only. There is no keyboard equivalent and no focus styling, so a keyboard user sees the surface at its resting angle — which is a fine outcome, but it is not an interactive control and should not be used as one.

The SVG filter's id comes from Vue's useId rather than a random string, so it is stable across server and client render. This matters: the surface references it from its own inline filter, and a hydration mismatch would leave the filter dangling and the plaster variant unlit until the next paint.

Under prefers-reduced-motion — read via useReducedMotion — the box-shadow transition is dropped entirely and pointer tracking is disabled, so the surface renders statically. Note this only removes the transition on box-shadow; the highlight/shade layers are still composed.

Performance

  • The whole style object is one computed. Nothing recomputes per pointer event except the two custom properties --es-dx and --es-dy, written directly to element.style — no Vue re-render per move.
  • The pointer only sets a direction, never a distance: the offset is normalized by the cursor's distance from centre and scaled by depth, so the relief depth stays a property of the material rather than of the pointer's distance.
  • The hover-capability check uses matchMedia with a change listener rather than a feature sniff, and the listener is removed in onBeforeUnmount.
  • plaster is the only variant that costs an SVG filter pass; the other three are pure box-shadow and (for metal-stamp) filter: drop-shadow, all handled by the compositor.
  • Shadow layers are emitted as CSS custom properties (calc(var(--es-blur) * 1px)), so the light angle can change without rebuilding the shadow string — only the custom properties are updated.
color is not decoration — it is load-bearing. Both the highlight and the shade are color-mix(in oklab, <color>, white|black N%), so an unresolvable color invalidates both and collapses the surface to no relief at all, with no error in the console. If a surface looks flat, check that color resolves before you touch depth or softness.
Copyright © 2026