Reveal

Content that fades and slides into place the first time it is scrolled into view, added with v-reveal.

Install

If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.

npx ermineui init
pnpm dlx ermineui init
yarn dlx ermineui init
bunx ermineui init

Then add the component:

npx ermineui add reveal
pnpm dlx ermineui add reveal
yarn dlx ermineui add reveal
bunx ermineui add reveal

That copies 3 files into src/components/ui/reveal/. Files you already have are kept.

Use

import { vReveal } from '@/components/ui/reveal'

In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island. The props are documented in the source below, on each one.

What gets copied

FileWhere it goes
directive.tssrc/components/ui/reveal/directive.ts
index.tssrc/components/ui/reveal/index.ts
reveal.csssrc/components/ui/reveal/reveal.css

The folders are the suggested ones. ermine.json says where yours are.

Source

This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.

directive.ts 44 lines
import type { Directive } from 'vue'

/**
 * v-reveal — fades an element in the first time it scrolls into view.
 *
 *   <section v-reveal>…</section>
 *   <li v-for="(item, index) in items" v-reveal="index * 80">…</li>    each a little after the last
 *
 * The value is a delay in milliseconds. Elements already on screen when the page loads are left
 * alone, so nothing above the fold flashes out and back in.
 */
let observer: IntersectionObserver | undefined

function watcher(): IntersectionObserver {
  observer ??= new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue
        ;(entry.target as HTMLElement).dataset.reveal = 'shown'
        // Once is enough: it does not hide again when scrolled past.
        observer!.unobserve(entry.target)
      }
    },
    // A little of it has to be showing before it starts, so the movement is actually seen.
    { threshold: 0.15, rootMargin: '0px 0px -5% 0px' },
  )
  return observer
}

export const vReveal: Directive<HTMLElement, number | undefined> = {
  mounted(el, binding) {
    if (typeof IntersectionObserver === 'undefined') return
    const box = el.getBoundingClientRect()
    const onScreen = box.top < window.innerHeight && box.bottom > 0
    if (onScreen) return
    if (binding.value) el.style.setProperty('--er-reveal-delay', `${binding.value}ms`)
    el.dataset.reveal = 'waiting'
    watcher().observe(el)
  },
  unmounted(el) {
    observer?.unobserve(el)
  },
}
index.ts 5 lines
/** Reveal — content that fades and slides into place the first time it is scrolled into view, added with v-reveal. */
import './reveal.css'

export { vReveal } from './directive'
reveal.css 24 lines
@layer components {
  /* Waiting below the fold: invisible and nudged down. The directive only sets this in a browser
     that can run it, so without JavaScript (or on the server) everything is simply visible. */
  [data-reveal='waiting'] {
    opacity: 0;
    transform: translateY(var(--er-reveal-distance, 1rem));
  }

  [data-reveal='shown'] {
    transition:
      opacity 500ms var(--er-ease, ease-out) var(--er-reveal-delay, 0ms),
      transform 500ms var(--er-ease, ease-out) var(--er-reveal-delay, 0ms);
  }

  /* No movement for anyone who has asked for less of it: the content is just there. */
  @media (prefers-reduced-motion: reduce) {
    [data-reveal] {
      opacity: 1;
      transform: none;
      transition: none;
    }
  }
}