Layr Components

Components / Text

Lit Text

A large statement whose words turn from quiet grey to ink one by one as it moves up the window; the opening words stay dark.

Scroll insideFull screen
Props
64 px
1.02
-0.04 em
400
#0E0E0D
#A3A3A0

Scroll inside the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Lit Text shows up in the Insert menu, and its controls appear in the right panel.
// Lit Text · Layr Components
//
// A large statement that is read as you scroll. The opening words are always in
// ink; the rest start quiet and turn to ink word by word as the paragraph moves
// up the window. Without JavaScript every word shows in ink.
// Paste into a new code file in Framer. Place it in the page flow with a fixed
// width; its height follows the text.

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

interface LitTextProps {
    lead: string
    text: string
    tag: string
    textSize: number
    lineHeight: number
    tracking: number
    weight: number
    fontFamily: string
    ink: string
    quiet: string
    style?: CSSProperties
}

const STYLES = `
.lyr-lt{margin:0;-webkit-font-smoothing:antialiased;font-synthesis:none;text-wrap:pretty;color:var(--lyr-lt-ink)}
.lyr-lt-w{transition:color .45s ease}
.lyr-lt-w[data-lit="0"]{color:var(--lyr-lt-quiet)}
@media (prefers-reduced-motion:reduce){.lyr-lt-w{transition:none}}
`

/**
 * @framerIntrinsicWidth 1000
 * @framerIntrinsicHeight 200
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function LitText(props: LitTextProps) {
    const {
        lead = "Read it as you scroll.",
        text = "The opening words stay dark. The rest turn from quiet grey to ink, one by one, as the paragraph moves up the window.",
        tag = "h2",
        textSize = 64,
        lineHeight = 1.02,
        tracking = -0.042,
        weight = 400,
        fontFamily = "Inter",
        ink = "#1C1B1A",
        quiet = "#A09D97",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const ref = useRef<HTMLElement>(null)
    const words = String(text).split(/\s+/).filter(Boolean)

    useEffect(() => {
        const el = ref.current
        if (!el || isStatic || typeof window === "undefined") return
        if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
        const spans = Array.from(el.querySelectorAll<HTMLElement>(".lyr-lt-w"))
        let lit = spans.length
        let raf = 0
        const read = () => {
            raf = 0
            const box = el.getBoundingClientRect()
            const vh = window.innerHeight
            const start = vh * 0.82
            const end = vh * 0.38
            const p = Math.max(0, Math.min(1, (start - box.top) / (start - end + box.height * 0.6)))
            const next = Math.round(p * spans.length)
            if (next === lit) return
            const from = Math.min(lit, next)
            const to = Math.max(lit, next)
            for (let i = from; i < to; i++) spans[i].dataset.lit = next > lit ? "1" : "0"
            lit = next
        }
        const request = () => {
            if (!raf) raf = requestAnimationFrame(read)
        }
        window.addEventListener("scroll", request, { passive: true })
        window.addEventListener("resize", request)
        read()
        return () => {
            window.removeEventListener("scroll", request)
            window.removeEventListener("resize", request)
            if (raf) cancelAnimationFrame(raf)
        }
    }, [text, isStatic])

    const t = String(tag).toLowerCase()
    const tagName = t.includes("h1") ? "h1" : t.includes("h3") ? "h3" : t.includes("p") ? "p" : "h2"
    const full = `${lead} ${text}`.trim()
    return createElement(
        tagName,
        {
            ref,
            className: "lyr-lt",
            "aria-label": full,
            style: {
                ...style,
                fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
                fontSize: textSize,
                lineHeight,
                letterSpacing: `${tracking}em`,
                fontWeight: weight,
                "--lyr-lt-ink": ink,
                "--lyr-lt-quiet": quiet,
            } as CSSProperties,
        },
        <style key="s" dangerouslySetInnerHTML={{ __html: STYLES }} />,
        <span key="l" aria-hidden="true">
            {lead}{" "}
        </span>,
        ...words.map((w, i) => (
            <span key={i} className="lyr-lt-w" data-lit="1" aria-hidden="true">
                {w}
                {i < words.length - 1 ? " " : ""}
            </span>
        ))
    )
}

addPropertyControls(LitText, {
    lead: { type: ControlType.String, title: "Opening", defaultValue: "Read it as you scroll.", description: "Always in ink." },
    text: {
        type: ControlType.String,
        title: "Text",
        defaultValue: "The opening words stay dark. The rest turn from quiet grey to ink, one by one, as the paragraph moves up the window.",
        displayTextArea: true,
        description: "Turns from quiet to ink as you scroll.",
    },
    tag: { type: ControlType.Enum, title: "Tag", options: ["h1", "h2", "h3", "p"], optionTitles: ["H1", "H2", "H3", "P"], defaultValue: "h2" },
    textSize: { type: ControlType.Number, title: "Text size", defaultValue: 64, min: 16, max: 200, step: 1, unit: "px" },
    lineHeight: { type: ControlType.Number, title: "Line height", defaultValue: 1.02, min: 0.8, max: 2, step: 0.01 },
    tracking: { type: ControlType.Number, title: "Letter spacing", defaultValue: -0.042, min: -0.1, max: 0.1, step: 0.001, unit: "em" },
    weight: { type: ControlType.Number, title: "Weight", defaultValue: 400, min: 200, max: 700, step: 100 },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "Use the family name of your text styles." },
    ink: { type: ControlType.Color, title: "Ink", defaultValue: "#1C1B1A" },
    quiet: { type: ControlType.Color, title: "Quiet", defaultValue: "#A09D97" },
})

In this preview

No pictures needed.