Layr Components

Components / Text

Foot Band

The site's name fitted to the full footer width over a thin band that fills with the accent as the visitor reaches the end of the page.

Scroll insideFull screen
Props
#0E0E0D
#FF4A1C
#E6E5E1
6 px
90%

Scroll to the end of the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Foot Band shows up in the Insert menu, and its controls appear in the right panel.
// Foot Band · Layr Components
//
// The site's name set as wide as the footer, standing on a thin band that fills
// from the left as the visitor reaches the end of the page. The name is fitted to
// the width whatever its length; leave Wordmark empty for the band alone.
// Paste into a new code file in Framer. Place it at the top of the footer, as wide
// as the footer's content; it sets its own height.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"

interface FootBandProps {
    wordmark: string
    fontFamily: string
    textColour: string
    fillColour: string
    trackColour: string
    bandHeight: number
    reach: number
    style?: CSSProperties
}

const SHEET = `
.lyr-fb{position:relative;width:100%;container-type:inline-size;--lyr-fb-p:1}
.lyr-fb-word{display:block;white-space:nowrap;overflow:hidden;font-weight:500;letter-spacing:-.045em;line-height:.86;font-size:calc(100cqw / var(--lyr-fb-em));margin:0 0 calc(100cqw * .018) -.03em;padding-bottom:.06em}
.lyr-fb-band{position:relative;width:100%;height:var(--lyr-fb-h);background:var(--lyr-fb-track);overflow:hidden}
.lyr-fb-band i{position:absolute;inset:0;display:block;background:var(--lyr-fb-fill);transform-origin:0 50%;transform:scaleX(var(--lyr-fb-p));will-change:transform}
.lyr-fb-probe{position:absolute;left:0;top:0;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none}
.lyr-fb-probe span{display:inline-block;white-space:nowrap;font-weight:500;letter-spacing:-.045em;font-size:100px}
@supports not (width:1cqw){.lyr-fb-word{font-size:12vw}}
@media (prefers-reduced-motion:reduce){.lyr-fb{--lyr-fb-p:1!important}}
`
const clamp = (v: number) => Math.min(1, Math.max(0, v))
// Rough advance widths in em, for the first paint before the real word has been measured.
const guess = (word: string) => {
    let w = 0
    for (const ch of word) w += /[ilj.,'’:;!|]/.test(ch) ? 0.26 : /[ftr ]/.test(ch) ? 0.36 : /[mw]/.test(ch) ? 0.86 : /[MW]/.test(ch) ? 0.92 : /[A-Z0-9&]/.test(ch) ? 0.68 : 0.57
    return Math.max(1, w - word.length * 0.045) * 1.05
}

/**
 * @framerIntrinsicWidth 1120
 * @framerIntrinsicHeight 280
 *
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight auto
 */
export default function FootBand(props: FootBandProps) {
    const { wordmark = "Studio", fontFamily = "Inter", textColour = "#F2F0EA", fillColour = "#7D92FF", trackColour = "#262623", bandHeight = 6, reach = 90, style } = props
    const isStatic = useIsStaticRenderer()
    const root = useRef<HTMLDivElement>(null)
    const probe = useRef<HTMLSpanElement>(null)
    const word = String(wordmark ?? "").trim()

    // Fit the word to the width once its font has loaded.
    useEffect(() => {
        const el = root.current,
            pr = probe.current
        if (!el || !pr || !word) return
        const fit = () => {
            const w = pr.getBoundingClientRect().width
            if (w > 0) el.style.setProperty("--lyr-fb-em", String(Math.round((w / 100 + 0.02) * 1000) / 1000))
        }
        fit()
        const fonts = document.fonts
        if (!fonts) return
        fonts.load(`500 100px ${getComputedStyle(pr).fontFamily}`).then(fit).catch(() => {})
        fonts.ready.then(fit)
        fonts.addEventListener("loadingdone", fit)
        return () => fonts.removeEventListener("loadingdone", fit)
    }, [word, fontFamily])

    useEffect(() => {
        const el = root.current
        if (!el || isStatic) return
        if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        let goal = 0,
            now = 0,
            raf = 0,
            last = 0
        const span = Math.max(0.2, (Number(reach) || 90) / 100)
        const read = () => {
            const vh = window.innerHeight,
                left = document.documentElement.scrollHeight - (window.scrollY + vh)
            goal = left <= 2 ? 1 : clamp(1 - left / (vh * span))
        }
        const tick = (t: number) => {
            const dt = last ? Math.min(64, t - last) : 16
            last = t
            now += (goal - now) * (1 - Math.exp(-dt / 110))
            if (Math.abs(goal - now) < 0.0006) now = goal
            el.style.setProperty("--lyr-fb-p", String(Math.round(now * 10000) / 10000))
            raf = now === goal ? 0 : requestAnimationFrame(tick)
            if (!raf) last = 0
        }
        const wake = () => {
            read()
            if (!raf) raf = requestAnimationFrame(tick)
        }
        read()
        now = goal
        el.style.setProperty("--lyr-fb-p", String(now))
        window.addEventListener("scroll", wake, { passive: true })
        window.addEventListener("resize", wake)
        return () => {
            window.removeEventListener("scroll", wake)
            window.removeEventListener("resize", wake)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [isStatic, reach])

    const vars = {
        "--lyr-fb-track": trackColour,
        "--lyr-fb-fill": fillColour,
        "--lyr-fb-h": Math.max(1, Number(bandHeight) || 6) + "px",
        "--lyr-fb-p": isStatic ? 0.62 : 1,
        "--lyr-fb-em": guess(word || "x").toFixed(3),
    } as CSSProperties
    return (
        <div ref={root} className="lyr-fb" style={{ ...style, ...vars, color: textColour, fontFamily: `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif` }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            {word ? <div className="lyr-fb-word">{word}</div> : null}
            {word ? (
                <div className="lyr-fb-probe" aria-hidden="true">
                    <span ref={probe}>{word}</span>
                </div>
            ) : null}
            <div className="lyr-fb-band">
                <i />
            </div>
        </div>
    )
}

addPropertyControls(FootBand, {
    wordmark: { type: ControlType.String, title: "Wordmark", defaultValue: "Studio", description: "Your name. It is fitted to the full width, so any length works." },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
    textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2F0EA" },
    fillColour: { type: ControlType.Color, title: "Fill colour", defaultValue: "#7D92FF" },
    trackColour: { type: ControlType.Color, title: "Track colour", defaultValue: "#262623" },
    bandHeight: { type: ControlType.Number, title: "Band height", min: 1, max: 40, step: 1, unit: "px", defaultValue: 6 },
    reach: { type: ControlType.Number, title: "Starts filling", min: 20, max: 300, step: 10, unit: "%", defaultValue: 90, description: "How far from the end of the page it starts to fill, as a share of the window height." },
})

In this preview

No pictures needed.