Back to Footers
Inspired by:Jolocky
Added on Oct 3, 2026
components/footers/letter-reveal/letter-reveal-footer.tsx
"use client"

import * as React from "react"

import { footerStyles } from "./footer-styles"
import { DiscordIcon, FacebookIcon, GitHubIcon, XIcon } from "./icons"
import { NewsletterForm } from "./newsletter-form"
import {
  type ContactDetailItem,
  type LetterRevealFooterProps,
  type NavColumnItem,
  type SocialLinkItem,
} from "./types"

const DEFAULT_SOCIAL_LINKS: SocialLinkItem[] = [
  { platform: "x", href: "#", label: "X" },
  { platform: "facebook", href: "#", label: "Facebook" },
  { platform: "github", href: "#", label: "GitHub" },
  { platform: "discord", href: "#", label: "Discord" },
]

const DEFAULT_CONTACT_DETAILS: ContactDetailItem[] = [
  {
    label: "Headquarters",
    value: "2210 Larkin Hollow, Portland,\nOR 97205",
    hideOnMobile: true,
  },
  {
    label: "Call us",
    value: "+1 (503) 555-0142",
    href: "tel:+15035550142",
    isLink: true,
    underlineOnHover: false,
  },
  {
    label: "Contact us",
    value: "hello@jolocky.org",
    href: "mailto:hello@jolocky.org",
    isLink: true,
    underlineOnHover: true,
  },
  {
    label: "Support hours",
    value: "Mon – Fri, 09.00am – 06.00pm",
    hideOnMobile: true,
  },
]

const DEFAULT_NAV_COLUMNS: NavColumnItem[] = [
  {
    title: "Navigation",
    links: [
      { label: "Home", href: "#" },
      { label: "Curriculum", href: "#" },
      { label: "About", href: "#" },
      { label: "Contact", href: "#" },
    ],
  },
  {
    title: "Resources",
    links: [
      { label: "Blog", href: "#" },
      { label: "FAQ", href: "#" },
      { label: "Forum", href: "#" },
    ],
  },
]

function renderMaskedText(text: string) {
  let charIndex = 0
  const lines = text.split("|")
  return lines.map((line, lineIdx) => {
    const words = line.split(" ")
    return (
      <span key={lineIdx} className="jf__line" aria-hidden="true">
        {words.map((word, wordIdx) => {
          const chars = Array.from(word)
          return (
            <span key={wordIdx} className="jf__word">
              {chars.map((char) => {
                const currentIndex = charIndex++
                return (
                  <span
                    key={currentIndex}
                    className="jf__char"
                    style={{ "--i": currentIndex } as React.CSSProperties}
                  >
                    {char}
                  </span>
                )
              })}
            </span>
          )
        })}
      </span>
    )
  })
}

const subscribeNever = () => () => {}

export function LetterRevealFooter({
  className = "",
  headlineDesktop = "Start coding with us,|for free.",
  headlineMobile = "Start coding|with us.",
  socialLinks = DEFAULT_SOCIAL_LINKS,
  contactDetails = DEFAULT_CONTACT_DETAILS,
  navColumns = DEFAULT_NAV_COLUMNS,
  supportHeadline = "Support Jolocky",
  supportDescription = "Jolocky is free and nonprofit. Help keep it that way.",
  donateHref = "#",
  newsletterPlaceholder = "Enter your email",
  copyrightText = "© Copyright 2026 Jolocky. All rights reserved",
  termsHref = "#",
  privacyHref = "#",
  onNewsletterSubmit,
}: LetterRevealFooterProps) {
  const footerRef = React.useRef<HTMLElement | null>(null)
  const [hasEntered, setHasEntered] = React.useState(false)
  // Without IntersectionObserver the headline is simply shown right away.
  const canObserve = React.useSyncExternalStore(
    subscribeNever,
    () => "IntersectionObserver" in window,
    () => true
  )
  const isRevealed = hasEntered || !canObserve

  // IntersectionObserver to trigger character slide-up reveal
  React.useEffect(() => {
    const footer = footerRef.current
    if (!footer || !canObserve) return

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          setHasEntered(true)
          observer.disconnect()
        }
      },
      { threshold: 0.25 }
    )
    observer.observe(footer)
    return () => observer.disconnect()
  }, [canObserve])

  const renderSocialIcon = (
    platform: "x" | "facebook" | "github" | "discord"
  ) => {
    switch (platform) {
      case "x":
        return <XIcon size={17} />
      case "facebook":
        return <FacebookIcon size={17} />
      case "github":
        return <GitHubIcon size={17} />
      case "discord":
        return <DiscordIcon size={17} />
    }
  }

  const rawDesktopText = headlineDesktop.replace(/\|/g, " ")
  const rawMobileText = headlineMobile.replace(/\|/g, " ")

  return (
    <footer
      ref={footerRef}
      className={`jf${isRevealed ? "is-revealed" : ""}${className ? ` ${className}` : ""}`}
    >
      <style>{footerStyles}</style>

      <div className="jf__inner">
        {/* Top Section */}
        <div className="jf__top">
          <div>
            {/* Desktop Headline */}
            <h2
              className="jf__title jf__desktop-only"
              aria-label={rawDesktopText}
            >
              {renderMaskedText(headlineDesktop)}
            </h2>

            {/* Mobile Headline */}
            <h2
              className="jf__title jf__mobile-only"
              aria-label={rawMobileText}
            >
              {renderMaskedText(headlineMobile)}
            </h2>

            {/* Social Buttons */}
            <div className="jf__socials">
              {socialLinks.map((item, idx) => (
                <a
                  key={idx}
                  className="jf__social"
                  href={item.href}
                  aria-label={item.label}
                  title={item.label}
                >
                  {renderSocialIcon(item.platform)}
                </a>
              ))}
            </div>
          </div>

          {/* Contact Info Grid */}
          <div className="jf__info">
            {contactDetails.map((item, idx) => (
              <div
                key={idx}
                className={item.hideOnMobile ? "jf__hide-mobile" : undefined}
              >
                <div className="jf__label">{item.label}</div>
                {item.isLink && item.href ? (
                  <a
                    className={`jf__value${item.underlineOnHover ? "jf__link" : ""}`}
                    href={item.href}
                  >
                    {item.value}
                  </a>
                ) : (
                  <div className="jf__value">
                    {typeof item.value === "string" && item.value.includes("\n")
                      ? item.value.split("\n").map((line, i, arr) => (
                          <React.Fragment key={i}>
                            {line}
                            {i < arr.length - 1 && <br />}
                          </React.Fragment>
                        ))
                      : item.value}
                  </div>
                )}
              </div>
            ))}
          </div>
        </div>

        <div className="jf__divider jf__divider--top" />

        {/* Columns Section */}
        <div className="jf__cols">
          {navColumns.map((col, idx) =>
            idx === 0 ? (
              <nav key={idx} aria-label="Footer navigation">
                <div className="jf__label">{col.title}</div>
                <div className="jf__list">
                  {col.links.map((link, linkIdx) => (
                    <a key={linkIdx} className="jf__link" href={link.href}>
                      {link.label}
                    </a>
                  ))}
                </div>
              </nav>
            ) : (
              <div key={idx}>
                <div className="jf__label">{col.title}</div>
                <div className="jf__list">
                  {col.links.map((link, linkIdx) => (
                    <a key={linkIdx} className="jf__link" href={link.href}>
                      {link.label}
                    </a>
                  ))}
                </div>
              </div>
            )
          )}

          {/* Support Block with Inverse Underline */}
          <div className="jf__support">
            <div>
              <div className="jf__label">{supportHeadline}</div>
              <p>{supportDescription}</p>
              <a className="jf__link jf__link--on jf__donate" href={donateHref}>
                Donate
              </a>
            </div>
            <a className="jf__donate-btn" href={donateHref}>
              Donate
            </a>
          </div>

          {/* Subscribe Pill Form */}
          <NewsletterForm
            placeholder={newsletterPlaceholder}
            onSubmit={onNewsletterSubmit}
          />
        </div>

        <div className="jf__divider" />

        {/* Bottom Bar */}
        <div className="jf__bottom">
          <div className="jf__legal">
            <a className="jf__link" href={termsHref}>
              Terms of Service
            </a>
            <span className="jf__copy jf__desktop-only">{copyrightText}</span>
            <a className="jf__link" href={privacyHref}>
              Privacy Policy
            </a>
          </div>
          <span className="jf__copy jf__mobile-only">{copyrightText}</span>
        </div>
      </div>
    </footer>
  )
}
Letter Reveal Outro
letter-reveal

Headquarters
2210 Larkin Hollow, Portland,
OR 97205
Support hours
Mon – Fri, 09.00am – 06.00pm
Resources
Support Jolocky

Jolocky is free and nonprofit. Help keep it that way.

Donate
Donate
© Copyright 2026 Jolocky. All rights reserved
Editorial & Typography Architecture
Built from scratch by Jolocky, with our own layout, color and motion.
Signature Standard
Precision interaction engineering with zero external runtime bloat.
Open Source Registry
Production-ready multi-file tree built for modern Next.js and Tailwind CSS.

Engineering Breakdown

Letter Reveal Outro — An editorial modern outro footer featuring a masked letter-by-letter slide-up headline, interactive newsletter pill with state machine & undo, inverse hover links, and responsive contact grids.
Every interaction state, motion curve, and responsive edge-case has been extracted and reconstructed for seamless integration into modern React applications.
01
Zero — Primitive Dependency & Pure Standalone Pattern: 100% pure React + Scoped CSS without third—party UI primitive dependencies.
02
Masked Character Reveal: Animates headline characters sequentially from behind a word mask with 22ms stagger delays when entering viewport.
03
Interactive Newsletter State Machine: Handles idle, email validation error, animated spinner loading, and success state with interactive Undo action.
04
Inverse Link Hover Mechanics: Inverts hover underline behavior on the donation call — to—action for tactile visual contrast.
05
Responsive Multi — Tier Layout: Adapts information hierarchy from a 4—column desktop layout to an optimized mobile stacked layout.
Last Updated
Oct 3, 2026
Tags & Specifications
Typography RevealLetter MaskInteractive NewsletterEditorialZero Primitive
Color Palette
#f7f7f4
#0d1811
#737a74
#ffffff
Technology Stack
Engineered using established and reliable modern web technologies for peak runtime performance and developer ergonomics.
React
TypeScript
Scoped CSS
Inline SVG
Zero Primitive