Reveal Footer
End with an invitation. Page content scrolls away to uncover a generous contact footer, oversized wordmark, and useful links.
'use client'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import './reveal-footer.css'
export interface RevealFooterLink {
label: string
href: string
}
export interface RevealFooterProps {
/** Opaque page content that scrolls away to uncover the footer. */
children?: ReactNode
title: string
brand: string
contact: RevealFooterLink
availability?: string
links?: RevealFooterLink[]
note?: string
legal?: string
reveal?: boolean
className?: string
}
function findScrollViewport(element: HTMLElement) {
let parent = element.parentElement
while (parent && parent !== document.body) {
if (/(auto|scroll|overlay)/.test(window.getComputedStyle(parent).overflowY)) return parent
parent = parent.parentElement
}
return null
}
/** A native-scroll footer, confined to its own content and nearest scroll viewport. */
export function RevealFooter({ children, title, brand, contact, availability, links = [], note, legal, reveal = true, className = '' }: RevealFooterProps) {
const root = useRef<HTMLDivElement>(null)
const footer = useRef<HTMLElement>(null)
const titleId = useId()
const [canReveal, setCanReveal] = useState(false)
useEffect(() => {
const element = root.current
const foot = footer.current
if (!element || !foot || !reveal || !children) {
setCanReveal(false)
return
}
const viewport = findScrollViewport(element)
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
let frame = 0
function measure() {
frame = 0
const availableHeight = viewport?.clientHeight ?? window.innerHeight
// A footer taller than its scrollport must remain in normal flow.
const fits = element!.clientWidth > 600 && foot!.getBoundingClientRect().height + 24 < availableHeight
setCanReveal(fits && !reducedMotion.matches)
}
function scheduleMeasure() {
if (!frame) frame = window.requestAnimationFrame(measure)
}
measure()
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(scheduleMeasure)
observer?.observe(foot)
observer?.observe(element)
if (viewport) observer?.observe(viewport)
window.addEventListener('resize', scheduleMeasure)
reducedMotion.addEventListener('change', scheduleMeasure)
return () => {
observer?.disconnect()
window.removeEventListener('resize', scheduleMeasure)
reducedMotion.removeEventListener('change', scheduleMeasure)
window.cancelAnimationFrame(frame)
}
}, [reveal, children])
return <div ref={root} className={`ac-reveal-footer ${className}`.trim()} data-reveal={canReveal ? 'true' : 'false'}>
{children && <div className="ac-reveal-footer-content">{children}</div>}
<footer
ref={footer}
className="ac-reveal-footer-panel"
aria-labelledby={titleId}
onFocusCapture={(event) => {
// :focus-within removes the overlap before bringing a keyboard target into view.
if (event.target instanceof HTMLElement) event.target.scrollIntoView({ block: 'nearest', behavior: 'instant' })
}}
>
<div className="ac-reveal-footer-topline"><span>THE NEXT CHAPTER</span>{availability && <span className="ac-reveal-footer-availability"><i aria-hidden="true" />{availability}</span>}</div>
<div className="ac-reveal-footer-invitation">
<h2 id={titleId}>{title}</h2>
<a className="ac-reveal-footer-contact" href={contact.href}><span>{contact.label}</span><span aria-hidden="true">↗</span></a>
</div>
<div className="ac-reveal-footer-details">{note && <p>{note}</p>}{links.length > 0 && <ul aria-label="More ways to connect">{links.map((link, index) => <li key={`${link.href}-${index}`}><a href={link.href}>{link.label}<span aria-hidden="true">↗</span></a></li>)}</ul>}</div>
<div className="ac-reveal-footer-wordmark"><span className="ac-reveal-footer-brand">{brand}</span><span className="ac-reveal-footer-registered" aria-hidden="true">®</span></div>
{legal && <p className="ac-reveal-footer-legal">{legal}</p>}
</footer>
</div>
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/reveal-footer.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { RevealFooter } from '@/components/anticraft/RevealFooter';
export function Example() {
return (
<RevealFooter
title={'What’s next\nstarts here.'}
brand="AFTERHOURS"
contact={{ label: 'Start a conversation', href: 'mailto:hello@example.com' }}
availability="Open for the right project"
links={[
{ label: 'Our work', href: '/work' },
{ label: 'Our approach', href: '/about' },
]}
note="Independent minds. Shared ambition. A small studio for brands going somewhere."
legal="AFTERHOURS © 2026"
>
<section style={{ minHeight: 720, padding: '64px 32px', color: '#30282d' }}>
<p>SELECTED WORK / 2026</p>
<h2 style={{ maxWidth: '12ch', fontSize: 'clamp(44px, 8vw, 100px)', fontWeight: 400, lineHeight: 1 }}>A different point of view.</h2>
<p>Place the page content that precedes your footer here.</p>
</section>
</RevealFooter>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | Required | The contact section heading. A newline preserves an intentional line break; longer text wraps naturally. |
brand | string | Required | A large text wordmark at the foot of the composition. Short studio or product names work best. It wraps rather than painting outside the component. |
contact | RevealFooterLink | Required | The main ordinary link: {label,href}. Use your real email, contact page, or booking destination. The demo uses a sample mailto address; it does not submit a form or book an appointment. |
children | ReactNode | undefined | The page content immediately before the footer. Its wrapper has an opaque --ac-footer-content background and sits above the sticky panel, so native scrolling uncovers the footer. Without preceding content the footer stays in normal flow. Keep all content in this component’s natural document order. |
availability | string | undefined | An optional availability line with a small decorative status dot. The text conveys the status independently of color. |
links | RevealFooterLink[] | [] | Secondary native links with {label,href}. Every link stays in the document and supports keyboard activation. The demo links return to real project and approach anchors in the preceding content. |
note / legal | string | undefined | Optional studio introduction and small copyright or attribution line. Long content increases the natural footer height; the component automatically falls back to ordinary scrolling if it no longer fits. |
reveal | boolean | true | Enable native scroll uncover when the panel fits the nearest scrollable ancestor or window with 24px spare. Containers 600px wide or narrower, reduced-motion preferences, and short scrollports use a static footer. Set false for a normal footer at every size. |
className | string | '' | Additional classes. Theme --ac-footer-surface, --ac-footer-ink, --ac-footer-muted, --ac-footer-line, --ac-footer-accent, and --ac-footer-content. The reveal uses a bottom-sticky panel inside this wrapper. Avoid an additional overflow ancestor between the component and your intended scrollport, and avoid transforms that change normal sticky positioning. |
Motion & accessibility
Native scrolling moves the preceding opaque content over a bottom-sticky footer; there is no scroll capture, global fixed layer, animation loop, or document-style mutation. A ResizeObserver rechecks the panel and nearest scrollport after sizing or content changes. The effect is enabled only when the complete footer fits with 24px spare and the component is wider than 600px. Reduced motion and short windows use the same complete composition in normal flow. Focusing any footer link immediately removes the overlap and scrolls that target into view, so keyboard users never have to activate an obscured control. All links are real native anchors. The demo uses original travel art direction, existing Anticraft coastal imagery, and a fictional studio.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.
Inspiration
The generous invitation and large brand signature are a starting point. Anticraft adds its own original composition, native scroll uncover, nearest-scrollport measurement, and a normal-flow keyboard fallback.
Visit Aceternity — Footer with Big TextThe component source and demo artwork are original to Anticraft UI. The linked site is the design or content reference.
