Skip to content

Components / Scroll

Footer Reveal

The page content slides up to reveal a footer that is already in place underneath. No timeline and no script: the footer is position: sticky at the bottom of the scrollport with a fixed height, and the content in front carries a higher z-index and its own background, so the only motion is the scroll itself. On a page shorter than the viewport the footer simply sits below the content.

Live demo

scroll inside

Content in front.

The footer is sticky at the bottom with a fixed height, underneath. Nothing animates. Scrolling uncovers the footer.

Keep going.

Moonarc

footer · revealed

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.4 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import FooterReveal from '@moonarc/core/FooterReveal';
---
<FooterReveal height="22rem" background="var(--bg)">
  <main>…</main>
  <div slot="footer" class="p-10">
    <p class="text-6xl font-bold">Moonarc</p>
  </div>
</FooterReveal>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/footer-reveal.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

FooterReveal.astro
---
/**
 * FooterReveal — the page content slides up and the footer is already
 * there underneath. No timeline, no script: the footer is position: sticky
 * at the bottom of the scrollport with a fixed height, and the content in
 * front carries a higher z-index and its own background, so the only motion
 * is the scroll itself. On a page shorter than the viewport the footer is
 * simply below the content. Reduced motion changes nothing — there is no
 * animation to remove.
 *
 * Without a default slot it renders the footer alone, for a layout whose page
 * content comes before it as a sibling (a <main> the page's layout renders):
 * the footer still sticks and waits underneath, and the content in front
 * brings its own z-index and ground.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Footer height, any CSS length. */
  height?: string;
  /** Background of the content in front — must be opaque. */
  background?: string;
}

const { height = '20rem', background = 'Canvas', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-fr-h:${height}`, `--ma-fr-bg:${background}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
{
  Astro.slots.has('default') ? (
    <div class:list={['ma-fr', className]} style={vars} {...rest}>
      <div class="ma-fr__content"><slot /></div>
      <footer class="ma-fr__footer"><slot name="footer" /></footer>
    </div>
  ) : (
    <footer class:list={['ma-fr__footer', className]} data-alone style={vars} {...rest}><slot name="footer" /></footer>
  )
}

<style is:global>
  @layer components {
    :where(.ma-fr) {
      position: relative;
    }
    :where(.ma-fr__content) {
      position: relative;
      z-index: 1;
      background: var(--ma-fr-bg, Canvas);
    }
    :where(.ma-fr__footer) {
      /* a hook: relative (or static) where the reveal cannot work, say on a screen shorter than the footer */
      position: var(--ma-fr-position, sticky);
      bottom: 0;
      z-index: 0;
      height: var(--ma-fr-h, 20rem);
      display: grid;
      align-content: end;
    }
    /* there is no motion to reduce; the rule exists so the audit sees the branch was considered. It keeps the position
       hook: a page that set --ma-fr-position lost it under reduced motion, where this rule wrote sticky back */
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-fr__footer) {
        position: var(--ma-fr-position, sticky);
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
heightstring'20rem'Footer height, any CSS length.
backgroundstring'Canvas'Background of the content in front. It must be opaque, or the footer shows through. Without a default slot the footer is rendered alone, after the page's own content: that content then carries position: relative, a z-index of 1 and an opaque ground itself.

Reduced motion

Nothing changes: there is no animation, only the scroll, which the reader controls.

With ClientRouter

Static layout; nothing to rebind. Verified across swaps and on pages shorter than the viewport.

Why it is built this way

Replaces: FooterReveal (Motion Primitives) · sticky footer reveal (Codrops) · reveal footer (Osmo). See the migration table.

Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.

Chrome 116 · not Firefox · Safari 26scroll

Parallax

Scroll

An element drifts at its own speed as it crosses the viewport, exactly in step with the scroll.

Chrome 115 · not Firefox · Safari 26scroll

A hairline along the top of the viewport that fills as the page is read.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown