---
name: card-reflection-effect
description: Use when you want "Premium, glossy, dramatic" - Adds a subtle mirrored reflection below or inside a card.
---

# Card Reflection Effect

> **Category:** Card / Layout  -  **Personality:** Premium, glossy, dramatic
>
> **Best use:** Product visuals, dark UI
>
> **Ratings:** Professional 3/5 - Casual 2/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

The card-reflection effect drops a mirror image of a card onto the "floor" just beneath it, as if the card were standing on polished black glass. You duplicate the card, flip the copy vertically with `transform: scaleY(-1)`, then fade it out with a `linear-gradient` `mask-image` so it is brightest at the card's base and gone a short way down. It is a cheap, pure-CSS way to make product cards, app mockups or hero cards feel premium and three-dimensional — and it only sells the illusion over a dark, slightly lit surface.

## Dependencies / CDN

None - pure CSS (`transform` + `mask-image`). The demo loads two Google Fonts (optional) and a tiny vanilla-JS pointer-tilt flourish that the effect itself does not need:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo+Expanded:wght@600;700;800&family=Hanken+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
```

## HTML

Render the card once for real, then a second time as an `inert`, `aria-hidden` copy — the reflection. Swap any `<button>` in the copy for a `<span>` so it can't be focused or clicked.

```html
<div class="cre-stage">
  <div class="cre-rig">

    <!-- The real, interactive card -->
    <article class="cre-card">
      <div class="cre-shot"><img src="sneaker.jpg" alt="Aerostride Pro running shoe"></div>
      <div class="cre-info">
        <h3 class="cre-name">Aerostride Pro</h3>
        <div class="cre-buy"><span class="cre-price">$184</span><button class="cre-cta">Add to bag</button></div>
      </div>
    </article>

    <!-- The reflection: an inert, flipped copy of the SAME card -->
    <div class="cre-reflection" aria-hidden="true" inert>
      <article class="cre-card">
        <div class="cre-shot"><img src="sneaker.jpg" alt=""></div>
        <div class="cre-info">
          <h3 class="cre-name">Aerostride Pro</h3>
          <div class="cre-buy"><span class="cre-price">$184</span><span class="cre-cta">Add to bag</span></div>
        </div>
      </article>
    </div>

  </div>
</div>
```

## CSS

```css
/* Dark, slightly-lit stage so the gloss has something to reflect */
.cre-stage{display:grid; place-items:center; min-height:640px; padding:58px 26px 34px;
  border-radius:26px; overflow:hidden;
  background:radial-gradient(125% 92% at 50% -12%,#241a12,#120e0c 45%,#08070a);
  box-shadow:inset 0 0 170px 34px rgba(0,0,0,.72);
  perspective:1500px}                          /* perspective = only for the optional tilt */
.cre-rig{display:flex; flex-direction:column; align-items:center; width:100%; max-width:366px;
  transform-style:preserve-3d}

/* The card (abbreviated) */
.cre-card{width:100%; border-radius:22px; overflow:hidden;
  background:linear-gradient(180deg,#1d1815,#110d0c);
  border:1px solid rgba(255,255,255,.10);
  box-shadow:0 46px 86px -34px rgba(0,0,0,.95)}
.cre-shot{aspect-ratio:4/3}
.cre-shot img{width:100%; height:100%; object-fit:cover; display:block}

/* ============ THE EFFECT ============ */
/* 1) A fixed-height, clipped box right under the card. Keep the fade-mask HERE,
      on the un-flipped wrapper, so the gradient lives in normal screen space:
      opaque next to the card -> transparent as it falls away. */
.cre-reflection{position:relative; width:100%; height:clamp(168px,46vw,244px);
  margin-top:2px;                               /* 0 = seamless mirror; a few px = a thin floor line */
  overflow:hidden; pointer-events:none;
  -webkit-mask-image:linear-gradient(to bottom, rgba(0,0,0,.62), rgba(0,0,0,.22) 48%, transparent 88%);
          mask-image:linear-gradient(to bottom, rgba(0,0,0,.62), rgba(0,0,0,.22) 48%, transparent 88%);
}
/* 2) The copied card: flip it. Default (centre) transform-origin keeps its box in
      place, so pinning it to top:0 brings the card's BOTTOM up to the mirror line. */
.cre-reflection .cre-card{position:absolute; top:0; left:0; width:100%;
  transform:scaleY(-1);                         /* mirror the copy */
  box-shadow:none;                              /* drop the copy's shadow */
  filter:blur(.4px);                            /* satin-floor softening (0 = sharp mirror) */
}

@media (prefers-reduced-motion:reduce){ .cre-rig{transition:none} }
```

## JavaScript

Not required - the reflection is pure CSS. Optional flourish: tilt the whole rig (card + reflection move as one) toward the pointer. Needs `perspective` on `.cre-stage` and `transform-style:preserve-3d` on `.cre-rig`.

```js
var stage = document.querySelector('.cre-stage'),
    rig   = document.querySelector('.cre-rig');
if (stage && rig && !matchMedia('(prefers-reduced-motion: reduce)').matches){
  stage.addEventListener('pointermove', function(e){
    var r = stage.getBoundingClientRect();
    var nx = (e.clientX - r.left)/r.width  - .5,
        ny = (e.clientY - r.top )/r.height - .5;
    rig.style.transform = 'rotateX('+(-ny*6).toFixed(2)+'deg) rotateY('+(nx*8).toFixed(2)+'deg)';
  });
  stage.addEventListener('pointerleave', function(){ rig.style.transform = ''; });
}
```

## How it works

- **Duplicate, don't reuse.** The reflection is a second, decorative copy of the card markup. Cloning keeps it portable across browsers (unlike `-webkit-box-reflect`).
- **`transform: scaleY(-1)`** mirrors the copy vertically. With the default centre origin its box stays put, so positioning it `absolute; top:0` inside the clipped wrapper brings the card's *bottom* edge up to meet the real card's bottom edge — the "mirror line".
- **A clipped, short wrapper** (`.cre-reflection`, `overflow:hidden`, fixed height) means you only ever see the part nearest the card and reclaim the vertical space the full-height copy would otherwise waste.
- **`mask-image: linear-gradient(...)`** is the whole trick: it fades the copy from semi-opaque at the top (touching the card) to fully transparent before the wrapper ends, so it reads as a reflection dying away on a floor, not a flipped clone.
- **The dark, faintly-lit stage** gives the gloss context; a tiny `blur()` mimics a real, imperfectly-polished surface.

## Customization

- **Glossiness:** raise the top alpha of the mask (`.62`) toward `1` for a hard mirror, or drop it to `~.3` for a ghostly hint.
- **Reflection length:** change the `height` of `.cre-reflection` and the `%` where the gradient reaches `transparent`.
- **Water line:** `margin-top` on `.cre-reflection` - `0` for a seamless mirror, `2-6px` for a thin glowing floor seam.
- **Surface feel:** `filter: blur()` on the copy - `0` = chrome mirror, `1-2px` = brushed/satin.
- **One-element shortcut:** `-webkit-box-reflect: below 2px linear-gradient(transparent, rgba(0,0,0,.6));` does it without a clone - but it is WebKit/Blink only (no Firefox), which is why the duplicate-node version above is the portable default.

## Accessibility & performance

- The copy is purely decorative: give it `aria-hidden="true"` **and** `inert`, swap interactive `<button>`s for `<span>`s, and leave the duplicated `<img alt="">` empty so nothing is announced or focusable twice.
- `pointer-events:none` on `.cre-reflection` stops the clipped, invisible lower half from ever swallowing a click.
- `mask-image` + a single static `transform` are GPU-friendly and don't repaint; never animate the blur radius.
- The reflection is static, so it is reduced-motion-safe by default - only gate the optional pointer-tilt/glow behind `matchMedia('(prefers-reduced-motion: reduce)')`.

## Gotchas

- **Needs the `-webkit-` prefix** on `mask-image` for Safari/iOS and older Chrome, or the reflection renders at full strength with no fade.
- **Keep the mask on the un-flipped wrapper.** Because the copy is flipped, a mask placed *on the flipped element* runs backwards and fades the wrong end. Masking `.cre-reflection` (screen space, opaque-top -> transparent-bottom) keeps the geometry intuitive.
- **A cloned node doesn't track JS state.** If the real card changes (a selected colour swatch, hover, live price) the reflection won't follow unless you sync it (the demo mirrors swatch clicks into the copy) or switch to `-webkit-box-reflect`.
- **Kill the copy's shadow** (`box-shadow:none`) or a stray drop-shadow leaks out under the "floor".
- **It only reads as gloss on a dark/curved surface.** On a flat light background a flipped copy just looks like an upside-down duplicate.
