Components / Text
Focus List
Names set as one paragraph of large type; one is sharp and the rest blur with distance, and scrolling or pointing moves the focus.
Scroll insideFull screen
Props
#0E0E0D
#6E6E6B
Set in code: Items. The preview fills these in with demo content.
Scroll inside the preview, or point at a name.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Focus List shows up in the Insert menu, and its controls appear in the right panel.
// Focus List · Layr Components
//
// A list of names set as one paragraph of large type. One name is sharp and
// the rest go soft with distance. Scrolling pulls focus down the list, and
// pointing at a name pulls focus to it. Good for clients, awards or places.
// Paste into a new code file in Framer. Set a width; the height follows the
// names. Add the names in the Items list; a name with a link becomes a link.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, type CSSProperties } from "react"
type Item = { title?: string; link?: string }
interface FocusListProps {
items: Item[]
preview: number
fontFamily: string
ink: string
muted: string
style?: CSSProperties
}
const ITEMS: Item[] = [
{ title: "North Press" },
{ title: "Field Journal" },
{ title: "Harbour Hotels" },
{ title: "Lowland Books" },
{ title: "Coastline Rail" },
{ title: "Ridge Studio" },
{ title: "Tide Magazine" },
{ title: "Vale & Co" },
]
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))
// One name is in focus; the further a name is from it in the list, the
// softer it gets.
function nameVars(i: number, f: number): Record<string, string> {
const d = Math.abs(i - f)
return {
"--b": Math.min(7, d * 3.4).toFixed(2),
"--o": (1 - Math.min(0.5, d * 0.28)).toFixed(3),
"--m": clamp(1 - d / 0.7, 0, 1).toFixed(3),
}
}
const SHEET = `
.lyr-fl{position:relative;width:100%;container-type:inline-size}
.lyr-fl-in{margin:0;padding:0;font-weight:400;font-size:clamp(30px,4.6cqw,64px);line-height:1.14;letter-spacing:-.04em}
.lyr-fl-n{display:inline;color:color-mix(in srgb,var(--ink) calc(var(--m) * 100%),var(--muted));filter:blur(calc(var(--b) * 1px));opacity:var(--o);text-decoration:none;cursor:default;transition:color .2s}
a.lyr-fl-n{cursor:pointer}
`
/**
* @framerIntrinsicWidth 1120
* @framerIntrinsicHeight 220
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight auto
*/
export default function FocusList(props: FocusListProps) {
const { items = ITEMS, preview = 2, fontFamily = "Inter", ink = "#ECE9E2", muted = "#8E8B84" } = props
const isStatic = useIsStaticRenderer()
const list = (items || []).filter((item) => item && item.title)
const root = useRef<HTMLDivElement>(null)
const names = useRef<(HTMLElement | null)[]>([])
const fRef = useRef(preview)
const hover = useRef<number | null>(null)
const wakeRef = useRef<() => void>(() => {})
const countRef = useRef(0)
const n = list.length
countRef.current = n
useEffect(() => {
const host = root.current
if (!host || isStatic || n < 1) return
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
let raf = 0
let last = 0
let goal = fRef.current
const read = () => {
const count = countRef.current
if (hover.current !== null) {
goal = hover.current
return
}
const r = host.getBoundingClientRect()
const vh = window.innerHeight
// Focus runs down the list while the block crosses the middle
// band of the window.
const t = clamp((vh * 0.82 - r.top) / (vh * 0.64 + r.height), 0, 1)
goal = t * Math.max(0, count - 1)
}
const paint = (f: number) => {
fRef.current = f
const count = countRef.current
for (let i = 0; i < count; i++) {
const el = names.current[i]
if (!el) continue
const vars = nameVars(i, f)
for (const name in vars) el.style.setProperty(name, vars[name])
}
}
const tick = (time: number) => {
raf = 0
const dt = Math.min(64, Math.max(1, time - last))
last = time
let f = reduced ? goal : fRef.current + (goal - fRef.current) * (1 - Math.exp(-dt / 140))
if (Math.abs(goal - f) < 0.003) f = goal
paint(f)
if (f !== goal) raf = requestAnimationFrame(tick)
}
const wake = () => {
read()
if (!raf) {
last = performance.now()
raf = requestAnimationFrame(tick)
}
}
wakeRef.current = wake
read()
paint(goal)
window.addEventListener("scroll", wake, { passive: true })
window.addEventListener("resize", wake)
return () => {
window.removeEventListener("scroll", wake)
window.removeEventListener("resize", wake)
if (raf) cancelAnimationFrame(raf)
}
}, [n, isStatic])
const f = isStatic ? clamp(preview, 0, Math.max(0, n - 1)) : fRef.current
const font = `"${fontFamily}", "Inter", system-ui, sans-serif`
const vars = { "--ink": ink, "--muted": muted } as CSSProperties
const enter = (i: number) => (e: { pointerType?: string }) => {
if (e.pointerType && e.pointerType !== "mouse") return
hover.current = i
wakeRef.current()
}
const leave = () => {
hover.current = null
wakeRef.current()
}
return (
<div ref={root} className="lyr-fl" style={{ ...props.style, ...vars, fontFamily: font }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<p className="lyr-fl-in" onPointerLeave={leave}>
{list.map((item, i) => {
const common = {
className: "lyr-fl-n",
style: nameVars(i, f) as CSSProperties,
onPointerEnter: enter(i),
ref: (el: HTMLElement | null) => {
names.current[i] = el
},
}
const text = item.title + (i < n - 1 ? "," : "")
return (
<span key={i}>
{item.link ? (
<a href={item.link} {...common}>
{text}
</a>
) : (
<span {...common}>{text}</span>
)}
{i < n - 1 ? " " : ""}
</span>
)
})}
</p>
</div>
)
}
addPropertyControls(FocusList, {
items: {
type: ControlType.Array,
title: "Items",
maxCount: 40,
control: {
type: ControlType.Object,
controls: {
title: { type: ControlType.String, title: "Name", defaultValue: "" },
link: { type: ControlType.Link, title: "Link" },
},
},
defaultValue: ITEMS,
description: "The names, in order. A name with a link becomes a link.",
},
preview: {
type: ControlType.Number,
title: "Canvas preview",
defaultValue: 2,
min: 0,
max: 30,
step: 1,
displayStepper: true,
description: "Which name is in focus on the canvas. The live site follows the scroll and the pointer.",
},
fontFamily: {
type: ControlType.String,
title: "Font family",
defaultValue: "Inter",
description: "Use the family name of your text styles.",
},
ink: { type: ControlType.Color, title: "Text colour", defaultValue: "#ECE9E2" },
muted: { type: ControlType.Color, title: "Muted colour", defaultValue: "#8E8B84" },
})
In this preview
No pictures needed.