---
title: "Emboss Surface"
description: "A material surface whose relief is built from stacked shadows or a lit SVG filter, with an optional pointer-tracked light angle."
canonical_url: "https://nuxt-motion-block.pages.dev/components/effects/emboss-surface"
---
# 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-shadow`s 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.

<code-preview label="Preview">
<div className="flex,justify-center,w-full,h-full">
<component-example name="emboss-surface-basic">



</component-example>
</div>

<template v-slot:code="">

```vue
<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>
```

</template>
</code-preview>

## 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.

<code-preview label="Preview">
<div className="w-full">
<component-example name="emboss-surface-custom">



</component-example>
</div>

<template v-slot:code="">
<playground-code name="EmbossSurface">



</playground-code>
</template>
</code-preview>

## Variants

<table>
<thead>
  <tr>
    <th>
      Variant
    </th>
    
    <th>
      Relief
    </th>
    
    <th>
      Blur
    </th>
    
    <th>
      Built from
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        emboss
      </code>
      
       <em>
        (default)
      </em>
    </td>
    
    <td>
      Raised, highlight top-left / shade bottom-right
    </td>
    
    <td>
      Soft (<code>
        softness
      </code>
      
       × 2.4)
    </td>
    
    <td>
      Two <code>
        box-shadow
      </code>
      
       layers
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        deboss
      </code>
    </td>
    
    <td>
      Pressed — same geometry, <code>
        inset
      </code>
      
       and inverted
    </td>
    
    <td>
      Soft
    </td>
    
    <td>
      Two <code>
        inset box-shadow
      </code>
      
       layers
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        plaster
      </code>
    </td>
    
    <td>
      Pressed, wide and shallow
    </td>
    
    <td>
      Widest (× 1.8)
    </td>
    
    <td>
      Two inset layers + an SVG <code>
        feSpecularLighting
      </code>
      
       filter
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        metal-stamp
      </code>
    </td>
    
    <td>
      Raised, hard zero-blur edges
    </td>
    
    <td>
      None (<code>
        0px
      </code>
      
      )
    </td>
    
    <td>
      Two hard layers + two <code>
        drop-shadow
      </code>
      
       layers
    </td>
  </tr>
</tbody>
</table>

`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

<component-props>
<component-prop name="variant" type="'emboss' | 'deboss' | 'plaster' | 'metal-stamp'" default="'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.

</component-prop>

<component-prop name="color" type="string" default="'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.

</component-prop>

<component-prop name="depth" type="number" default="2">

Relief 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.

</component-prop>

<component-prop name="softness" type="number" default="0.5">

Shadow diffusion from `0` (hard) to `1` (soft). Multiplies the blur; has no
effect on `metal-stamp`, whose hard layers are explicitly `0px`.

</component-prop>

<component-prop name="lightAngle" type="number" default="135">

Direction 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.

</component-prop>

<component-prop name="interactive" type="boolean" default="false">

Let 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`.

</component-prop>

<component-prop name="as" type="string" default="'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.

</component-prop>

<component-prop name="class" type="string" default="''">

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.

</component-prop>
</component-props>

## Slots

<component-slots>
<component-slot-row name="default">

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

</component-slot-row>
</component-slots>

## UI

This component's theme exposes the slots below. Override any of them per instance with the [`ui` prop](https://nuxt-motion-block.pages.dev/getting-started/theming), or app-wide under `ui.embossSurface` in `app.config.ts`.

<component-props>
<component-prop name="ui" type="object">

Per-slot classes merged over the theme defaults.

</component-prop>
</component-props>

<component-slots>
<component-slot-row name="root">

`text-default`

</component-slot-row>

<component-slot-row name="filter">

`pointer-events-none absolute h-0 w-0`

</component-slot-row>
</component-slots>

## 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.

<tip>

`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`.

</tip>


## Sitemap

See the full [sitemap](https://nuxt-motion-block.pages.dev/sitemap.md) for all pages.
