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
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.