---
name: smooth-reveal-animation
description: Use when you want "Professional, subtle, clean" - Fades, slides, or scales content into view smoothly.
---

# Smooth Reveal Animation

> **Category:** Motion / Interaction  -  **Personality:** Professional, subtle, clean
>
> **Best use:** Corporate sites, blogs, SaaS
>
> **Ratings:** Professional 5/5 - Casual 4/5 - Exotic 2/5 - Visual impact 3/5 - Difficulty 2/5 - Performance cost 2/5

## What it is

The everyday, professional content reveal: a section's heading, copy, buttons and media start slightly lowered and transparent, then **ease up into place with a gentle fade** the moment the section enters the viewport. It is deliberately restrained — a short translate, a soft opacity fade, a refined ease-out, and **no blur** (that keeps it distinct from "Scroll-Blur-Reveal"). A small per-element delay (`--sr-d`) cascades the pieces so the section assembles itself like a calm, intentional gesture rather than a flashy entrance. Ideal when you want polish without drawing attention to the animation itself.

## Dependencies / CDN

**None** - pure CSS transitions + vanilla JS (`IntersectionObserver`). The demo only loads two Google Fonts for styling, which are optional:

```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=Onest:wght@400;500;600;700&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;1,6..72,500&display=swap" rel="stylesheet">
```

## HTML

Mark each block you want to reveal with `.sr-reveal` and give it an optional cascade delay via the `--sr-d` custom property. They live inside the section you will observe:

```html
<section class="sr-stage">
  <button class="sr-replay" id="sr-replay" type="button">&#8635; Replay reveal</button>

  <span class="sr-reveal" style="--sr-d:0ms">Our approach</span>
  <h2   class="sr-reveal" style="--sr-d:90ms">Considered advice, delivered with <em>quiet confidence</em>.</h2>
  <p    class="sr-reveal" style="--sr-d:170ms">Meridian partners with leadership teams to make the next decision the obvious one.</p>
  <div  class="sr-reveal" style="--sr-d:250ms"><button>Book a consultation</button></div>

  <figure class="sr-media sr-reveal" style="--sr-d:140ms">
    <img src="/path/to/architecture-01.jpg" alt="Headquarters">
  </figure>
</section>
```

## CSS

The whole effect is these few rules (theme/layout styling omitted). Note the hidden state is scoped to `.sr-armed`, which JS adds — so **no-JS visitors still see all content**:

```css
.sr-stage{ --sr-ease:cubic-bezier(.22,1,.36,1); }

/* Hidden start state — only once JS has "armed" the section */
.sr-stage.sr-armed .sr-reveal{
  opacity:0;
  transform:translateY(20px);                 /* small translate, NO blur */
  transition:opacity .9s var(--sr-ease), transform .9s var(--sr-ease);
  transition-delay:var(--sr-d,0ms);           /* per-element cascade */
  will-change:opacity,transform;
}
/* The media block gets a barely-there settle scale as well */
.sr-stage.sr-armed .sr-media.sr-reveal{ transform:translateY(22px) scale(.986); }

/* Revealed state */
.sr-stage.sr-armed .sr-reveal.is-in{ opacity:1; transform:none; }

/* Used to snap state changes without a transition (initial arm + replay reset) */
.sr-stage .sr-noanim{ transition:none !important; }

/* Reduced motion: content is simply SHOWN, no movement */
@media (prefers-reduced-motion: reduce){
  .sr-stage.sr-armed .sr-reveal{ opacity:1 !important; transform:none !important; transition:none !important; }
}
```

## JavaScript

Arms the section (hiding it instantly, never as a fade-out), reveals the cascade when it scrolls into view, and wires a Replay button:

```js
(function(){
  var stage=document.querySelector('.sr-stage'); if(!stage)return;
  var items=[].slice.call(stage.querySelectorAll('.sr-reveal'));
  var btn=document.getElementById('sr-replay');
  var reduce=window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;

  function reveal(el){ el.classList.add('is-in'); }
  function revealAll(){ items.forEach(reveal); }

  // Hide INSTANTLY (sr-noanim) before enabling transitions, so nothing fades out on load.
  function arm(){
    items.forEach(function(el){ el.classList.add('sr-noanim'); });
    stage.classList.add('sr-armed');
    void stage.offsetWidth;                       // commit hidden state with no transition
    items.forEach(function(el){ el.classList.remove('sr-noanim'); });
  }

  if(reduce){
    /* never arm -> content stays shown, no motion */
  }else{
    arm();
    if('IntersectionObserver' in window){
      var io=new IntersectionObserver(function(entries){
        entries.forEach(function(e){ if(e.isIntersecting){ revealAll(); io.disconnect(); } });
      },{threshold:0.18});
      io.observe(stage);                           // fires on load OR on scroll-in
    }else{ revealAll(); }
  }

  function replay(){
    if(btn){ btn.classList.remove('is-spin'); void btn.offsetWidth; btn.classList.add('is-spin'); }
    if(reduce || !stage.classList.contains('sr-armed')) return;
    items.forEach(function(el){ el.classList.add('sr-noanim'); el.classList.remove('is-in'); });
    void stage.offsetWidth;                        // snap back to hidden, no fade-out
    items.forEach(function(el){ el.classList.remove('sr-noanim'); });
    void stage.offsetWidth;                        // re-enable transitions
    requestAnimationFrame(revealAll);              // ease the whole cascade in again
  }
  if(btn) btn.addEventListener('click', replay);
})();
```

## How it works

- **Two states, one transition.** `.sr-reveal` is hidden (`opacity:0`, `translateY(20px)`); adding `.is-in` returns it to `opacity:1; transform:none`. The CSS `transition` does the rest — there is no per-frame JS.
- **`IntersectionObserver` is the trigger.** Observing the *section* (not every element) reveals the whole group as one cohesive cascade the moment it enters view; `io.disconnect()` makes it a one-shot. The `--sr-d` delays stagger the children inside that single reveal.
- **The ease-out is the personality.** `cubic-bezier(.22,1,.36,1)` decelerates hard at the end, so elements glide to a soft stop — the "premium" feel. A long `.9s` duration over a *small* 20px move keeps it calm.
- **Progressive enhancement.** The hidden state only applies under `.sr-armed`, which JS adds. If JS never runs, every block is visible. Arming with `.sr-noanim` first guarantees the initial hide is instantaneous, never a visible flicker.

## Customization

- **Direction:** swap `translateY(20px)` for `translateX(-24px)` (reveal from the side) or just drop the transform for a pure fade.
- **Pace:** raise the duration toward `1.1s` for an even slower, more luxurious settle; shorten the `--sr-d` deltas (e.g. 60ms steps) to tighten the cascade.
- **Per-element vs. per-section:** observe each `.sr-reveal` individually (`items.forEach(el=>io.observe(el))`) if you want blocks to reveal independently as the user scrolls a long page.
- **Travel distance:** keep it under ~24px — large translates read as "slide-in," not "reveal," and break the understated tone.

## Accessibility & performance

- **Animate only `opacity` and `transform`** — both are GPU-compositable and never trigger layout/paint, so this stays smooth even with many elements (Performance cost 2/5).
- **`prefers-reduced-motion`** is honoured two ways: JS skips arming (content shown immediately) *and* a CSS media query force-shows any armed element — so reduced-motion users always get the final, fully-visible layout with zero movement.
- **Never hide content on no-JS / failure.** Because the hidden state is gated behind `.sr-armed`, search engines and JS-disabled browsers see everything.
- Drop `will-change` if you reveal hundreds of elements at once; keeping it promoted permanently can waste memory.

## Gotchas

- **Introducing `opacity:0` and the `transition` in the same class toggle can make content fade *out* on load.** Always arm with transitions disabled first (the `.sr-noanim` + reflow trick), then re-enable, then reveal.
- **Forgetting to disconnect/`unobserve`** keeps the observer firing; here it is a one-shot, but a per-element version must `unobserve` each target after revealing or replays/animations re-trigger unexpectedly.
- **`will-change` is a promise, not a freebie** — listing it on too many elements can hurt rather than help.
- **A replay that just removes `.is-in`** will animate the *hide* (a slow fade-out). Snap the reset instantly with `.sr-noanim` + a forced reflow, then re-add `.is-in` on the next frame.
- **Catalog/global reduced-motion resets** (`transition-duration:.001ms`) already neutralise transitions, but always set the *final visible state* too, or content can get stuck at `opacity:0`.
