Layr Components

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