Blocks

Sticky Footer Reveal

A footer that sits behind the page — the content is an opaque sheet on a higher z-index and the last screenful of scroll slides it off the pinned footer, with a plain in-flow fallback when nothing scrolls.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Where the reveal currently sits. Published as `data-state` and through `onRevealChange`. */
export type FooterRevealState = "hidden" | "revealing" | "revealed"

export interface StickyFooterRevealProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
  /** The page itself — everything that scrolls up and off the footer. */
  children: React.ReactNode
  /** Footer content. It is rendered inside this component's own `<footer>` element. */
  footer: React.ReactNode
  /** Reserved footer height in px. It is a *min* height: taller footer content still grows past it. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/sticky-footer-reveal.json

Prompt

Build a React + TypeScript + Tailwind "StickyFooterReveal" block (React only, no
animation library).

Contract
- export const StickyFooterReveal = forwardRef<HTMLDivElement, Props>, plus
  export type FooterRevealState = "hidden" | "revealing" | "revealed".
- Props extend Omit<HTMLAttributes<HTMLDivElement>, "children"> and spread the rest
  onto the root: children (the page), footer (ReactNode rendered inside the
  component's own <footer>), height = 320 (px, used as a MIN height so a taller
  footer still grows), shadow = true, disabled = false, target?:
  RefObject<HTMLElement | null> (the scrolling container; omit for the window),
  sheetClassName, footerClassName, onRevealChange?(state).
- The root publishes --footer-h, --footer-reveal (0 → 1), data-mode
  ("reveal" | "static") and data-state; the <footer> repeats data-mode/data-state so
  footerClassName can style off them directly.

Behavior
- The reveal is layout, never animation. One wrapper (relative isolate) holds two
  layers: the sheet (relative z-10, opaque bg, holds children) and the <footer>
  (position: sticky; bottom: 0; z-0). Sticky keeps the footer's own space reserved at
  the end of the document while pinning it to the bottom of whatever scrollport it
  lands in, so the page's last screenful of scroll slides the sheet up off it. No
  scroll hijacking, no transform on the page, and it still works with JS idle.
- Sticky (not fixed) is what makes the same component work on the page and inside a
  scrolling panel. Document the one caveat: an ancestor between the component and the
  scrollport with overflow hidden/clip (or overflow-x: hidden, which computes
  overflow-y to auto) becomes the box the footer pins to, and the reveal stops.
- One rAF-throttled pass, attached once: window scroll in CAPTURE phase + passive
  (scroll events don't bubble but do propagate down, so one listener covers the
  window and any nested container), a passive resize listener, and a ResizeObserver
  on the sheet and the footer for layout changes that fire no scroll event. The pass
  reads target?.current fresh each tick (the container may mount late), computes
  progress = clamp((scrollportBottom - sheetRect.bottom) / footerHeight, 0, 1) and
  writes it to --footer-reveal straight on the DOM node — no React state per frame.
  Scrollport bottom = container rect bottom, or documentElement.clientHeight (not
  innerHeight, which includes a horizontal scrollbar).
- React re-renders only when the coarse bucket changes: revealed (>= 1 - 0.005),
  hidden (<= 0.005), otherwise revealing. Emit onRevealChange from a ref so a new
  callback identity never re-attaches listeners, and dedupe through a ref so a
  bucket is reported once per crossing.
- Short-page fallback, no prop needed: when the scrollport has no scroll range
  (scrollHeight - clientHeight <= 0) sticky is already a no-op and the footer is
  sitting in flow — force progress to 1 and report data-mode="static" rather than
  leaving the footer parked at 0.
- disabled: render an ordinary in-flow footer (no pin, no shadow, no parallax) and
  attach no listeners at all; set --footer-reveal to 1 and report "revealed".
- Keyboard: the footer is pinned inside the viewport the whole time, so the browser
  believes a focused link down there is already visible and scrolls nowhere. Listen
  for focusin on the footer and scrollBy the remaining gap (sheetRect.bottom -
  (scrollportBottom - footerHeight)), behavior "smooth", or "auto" under reduced
  motion. Gate it on event.target.matches(":focus-visible") so clicking a
  half-revealed link never yanks the page out from under the pointer. Never use
  inert / tabindex=-1 to hide the covered footer.
- Cleanup is total: remove the capture-phase scroll listener with { capture: true },
  the resize listener and the focusin listener, disconnect the ResizeObserver and
  cancelAnimationFrame the pending frame.
- SSR: no window/document access during render. Read prefers-reduced-motion with
  useSyncExternalStore (matchMedia change listener + a false server snapshot) so a
  mid-session OS change re-renders and the listener is cleaned up.

Rendering & styling
- Semantic tokens only: bg-background + border-b on the sheet, bg-muted on the
  footer, shadow-2xl for the cast shadow, no colour literals anywhere.
- Footer motion is layout-free and CSS-only, both ramps reading the one custom
  property: opacity: calc(0.55 + 0.75 * var(--footer-reveal)) (CSS clamps it, so it
  is fully opaque by ~60% of the reveal) and transform: translateY(calc((1 -
  var(--footer-reveal)) * -8%)) so the footer settles into place instead of sitting
  dead still. Both are dropped entirely under reduced motion and in static mode, and
  --footer-reveal ships at 1 in the root's inline style so the footer is legible
  before hydration and if JS never runs.
- The footer is a real <footer> landmark with its content in normal source order —
  it is never aria-hidden and never removed from the tab order just because pixels
  cover it. cn() merges className onto the root.

Customization levers
- Depth: height sets how much scroll the reveal costs — 72 for a legal bar, 320-480
  for a sitemap footer. Children can size themselves off var(--footer-h).
- Materials: sheetClassName swaps the sheet surface (add rounded-b-2xl for a card
  lifting away, border-b-0 to drop the seam); footerClassName swaps the footer
  surface (bg-card, a gradient, an image) and can key off
  data-[state=revealed]: / data-[mode=static]:.
- Shadow: shadow={false} for a flat two-layer look, or override with your own
  shadow-* / drop-shadow on sheetClassName.
- Motion strength: change the -8% translate and the 0.55 opacity floor, or delete
  the parallax style object for a footer that never moves.
- Scope: pass target for a scrolling panel, omit it for the page; wrap only the
  section you want on top of the footer rather than the whole page.
- Reporting: onRevealChange drives anything outside the block — a nav that inverts,
  a CTA bar that hides, an analytics ping when the footer is finally seen.

Concepts

  • Sheet and desk — the reveal is two layers and one z-index, not a scroll animation: the page is an opaque sheet on z-10, the footer is pinned underneath on z-0. Everything the user reads as motion is just the sheet's bottom edge travelling up past a footer that never moved.
  • Sticky, not fixedposition: sticky; bottom: 0 pins the footer to whatever scrollport it lands in (the window, or a scrolling panel) and keeps its own height reserved at the end of the document, which is what gives the reveal exactly height pixels of scroll to happen in. fixed would leak out of nested containers and need a manual spacer.
  • Progress as a custom property — the scroll pass never touches React: it writes --footer-reveal (0 → 1) onto the root and lets CSS do the parallax settle and opacity ramp. React renders only when the coarse bucket flips to hidden / revealing / revealed.
  • Short-page fallback for free — when nothing scrolls, position: sticky is already a no-op and the footer is simply the last block on the page; the component notices (data-mode="static"), forces progress to 1 and stops pretending a reveal is pending, so a two-paragraph page never ships a footer stuck at 0.
  • Covered is not hidden — the footer stays a real <footer> landmark in source order, never aria-hidden, never inert. Because it is pinned inside the viewport the browser thinks focused links down there are already visible, so focusin finishes the reveal itself — gated on :focus-visible, so tabbing scrolls the last stretch but clicking a half-revealed link never yanks the page.
  • Reduced motion keeps the feature — the reveal is layout, so it still happens with motion off; only the parallax and the opacity ramp are dropped. --footer-reveal also ships at 1 in the root's inline style, so a footer that never gets a scroll pass renders fully legible instead of stuck at its starting frame.

On This Page