Layr Components

Components / Text

Plate Text

A large sentence with small round pictures set into the line that slowly turn; the words light up one by one as you scroll.

Scroll insideFull screen
Props
56 px
30%
400
1.08
-0.04 em
1 em
0.15 em
40 s
#0E0E0D
#A3A3A0

Set in code: Plate 1, Plate 2, Plate 3, Plate 4. The preview fills these in with demo content.

Scroll inside the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Plate Text shows up in the Insert menu, and its controls appear in the right panel.
// Plate Text · Layr Components
//
// A large sentence with small round pictures set into the line like words.
// Each picture turns slowly; as the text scrolls into view the words light up
// one by one. Every plate mark (*) in the text becomes a picture, and a mark
// inside a word (W*rd) makes a wordmark.
// Paste into a new code file in Framer. Give it a width; its height follows.
// Without pictures the plates are plain discs in the dim colour.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useInView, useMotionValueEvent, useReducedMotion, useScroll } from "framer-motion"
import { useCallback, useEffect, useRef, type CSSProperties } from "react"

interface Img {
    src: string
    srcSet?: string
    alt?: string
}

interface PlateTextProps {
    text: string
    token: string
    image1?: Img
    image2?: Img
    image3?: Img
    image4?: Img
    sizeMode: string
    fontSize: number
    fitSize: number
    fontWeight: number
    lineHeight: number
    tracking: number
    align: string
    plateSize: number
    plateShift: number
    spin: number
    reveal: boolean
    color: string
    dimColor: string
    fontFamily: string
    style?: CSSProperties
}

const SHEET = `
.lyr-pt { container-type: inline-size; }
.lyr-pt .lyr-pt-text { margin: 0; font-family: var(--lyr-pt-family); font-weight: var(--lyr-pt-weight); line-height: var(--lyr-pt-lh); letter-spacing: var(--lyr-pt-track); text-align: var(--lyr-pt-align); color: var(--lyr-pt-color); font-size: var(--lyr-pt-size); text-wrap: balance; }
.lyr-pt .lyr-pt-word { transition: color .35s ease; }
.lyr-pt .lyr-pt-plate { display: inline-block; width: var(--lyr-pt-plate); height: var(--lyr-pt-plate); vertical-align: baseline; margin: 0 0.06em; transition: opacity .35s ease, transform .5s cubic-bezier(.2,.8,.2,1); }
.lyr-pt .lyr-pt-spin { display: block; width: 100%; height: 100%; border-radius: 50%; background: var(--lyr-pt-dim) center / cover no-repeat; box-shadow: 0 0.12em 0.2em -0.06em rgba(40,25,5,0.4); animation: lyr-pt-turn var(--lyr-pt-spin) linear infinite; animation-play-state: var(--lyr-pt-play); }
.lyr-pt .lyr-pt-rev { animation-direction: reverse; }
@keyframes lyr-pt-turn { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .lyr-pt .lyr-pt-spin { animation: none; } }
`

const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v))

/**
 * @framerIntrinsicWidth 900
 * @framerIntrinsicHeight 300
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function PlateText(props: PlateTextProps) {
    const {
        text = "Little round pictures * sit in the line like words, * turn slowly as you read, * and the sentence lights up as you scroll.",
        token = "*",
        image1,
        image2,
        image3,
        image4,
        sizeMode = "px",
        fontSize = 56,
        fitSize = 30,
        fontWeight = 400,
        lineHeight = 1.08,
        tracking = -0.04,
        align = "left",
        plateSize = 1,
        plateShift = 0.15,
        spin = 40,
        reveal = true,
        color = "#1B1A17",
        dimColor = "#A39C8C",
        fontFamily = "Inter",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const reduced = useReducedMotion()
    const root = useRef<HTMLDivElement>(null)
    const parts = useRef<(HTMLSpanElement | null)[]>([])
    const inView = useInView(root, { amount: 0.05 })
    const { scrollYProgress } = useScroll({ target: root, offset: ["start 0.88", "end 0.5"] })

    const images = [image1, image2, image3, image4].filter((i): i is Img => !!i?.src)
    const fit = String(sizeMode).toLowerCase().includes("width") || String(sizeMode).toLowerCase().includes("fit")
    const live = reveal && !isStatic && !reduced

    // Split into words and plates, keeping each plate glued to nothing so lines can wrap around it.
    const tk = token || "*"
    const pieces: { plate: number; word: string }[] = []
    let n = 0
    String(text || "")
        .split(/(\s+)/)
        .forEach((chunk) => {
            if (!chunk.trim()) return
            if (chunk === tk) pieces.push({ plate: n++, word: "" })
            // a token inside a word (a wordmark such as W*rd) keeps the word whole
            else pieces.push({ plate: -1, word: chunk })
        })

    const paint = useCallback(
        (p: number) => {
            const total = parts.current.length
            for (let i = 0; i < total; i++) {
                const el = parts.current[i]
                if (!el) continue
                const t = live ? clamp(p * (total + 3) - i) : 1
                if (el.dataset.plate) {
                    el.style.opacity = String(0.25 + 0.75 * t)
                    el.style.transform = `translateY(${plateShift}em) scale(${(0.6 + 0.4 * t).toFixed(3)})`
                } else el.style.color = t > 0.5 ? color : dimColor
            }
        },
        [live, color, dimColor, plateShift]
    )
    useMotionValueEvent(scrollYProgress, "change", paint)
    useEffect(() => {
        paint(scrollYProgress.get())
    })

    const plate = (index: number, key: string, refIndex?: number) => {
        const im = images.length ? images[index % images.length] : undefined
        return (
            <span
                key={key}
                data-plate="1"
                ref={
                    refIndex == null
                        ? undefined
                        : (el) => {
                              parts.current[refIndex] = el
                          }
                }
                className="lyr-pt-plate"
                style={{ transform: `translateY(${plateShift}em)` }}
            >
                <span className={`lyr-pt-spin ${index % 2 ? "lyr-pt-rev" : ""}`} style={{ backgroundImage: im ? `url("${im.src}")` : undefined }} role={im?.alt ? "img" : undefined} aria-label={im?.alt || undefined} />
            </span>
        )
    }

    parts.current.length = pieces.length
    let inline = 0
    return (
        <div
            ref={root}
            className="lyr-pt"
            style={
                {
                    ...style,
                    position: "relative",
                    width: "100%",
                    "--lyr-pt-family": `"${fontFamily}", "Inter", system-ui, sans-serif`,
                    "--lyr-pt-weight": fontWeight,
                    "--lyr-pt-lh": lineHeight,
                    "--lyr-pt-track": `${tracking}em`,
                    "--lyr-pt-align": String(align).toLowerCase(),
                    "--lyr-pt-color": color,
                    "--lyr-pt-dim": dimColor,
                    "--lyr-pt-size": fit ? `${fitSize}cqw` : `${fontSize}px`,
                    "--lyr-pt-plate": `${plateSize}em`,
                    "--lyr-pt-spin": `${Math.max(4, spin)}s`,
                    "--lyr-pt-play": inView && !isStatic && spin > 0 ? "running" : "paused",
                } as CSSProperties
            }
        >
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <p className="lyr-pt-text">
                {pieces.map((piece, i) => {
                    if (piece.plate >= 0)
                        return (
                            <span key={i}>
                                {plate(piece.plate, `p${i}`, i)}{" "}
                            </span>
                        )
                    if (tk.length === 1 && piece.word.includes(tk)) {
                        const bits = piece.word.split(tk)
                        return (
                            <span
                                key={i}
                                className="lyr-pt-word"
                                ref={(el) => {
                                    parts.current[i] = el
                                }}
                                style={{ whiteSpace: "nowrap" }}
                            >
                                {bits.map((bit, j) => (
                                    <span key={j}>
                                        {bit}
                                        {j < bits.length - 1 ? plate(inline++, `w${i}-${j}`) : null}
                                    </span>
                                ))}{" "}
                            </span>
                        )
                    }
                    return (
                        <span
                            key={i}
                            className="lyr-pt-word"
                            ref={(el) => {
                                parts.current[i] = el
                            }}
                        >
                            {piece.word}{" "}
                        </span>
                    )
                })}
            </p>
        </div>
    )
}

addPropertyControls(PlateText, {
    text: { type: ControlType.String, title: "Text", defaultValue: "Little round pictures * sit in the line like words, * turn slowly as you read, * and the sentence lights up as you scroll.", displayTextArea: true },
    token: { type: ControlType.String, title: "Plate mark", defaultValue: "*", description: "Each of these in the text becomes a plate." },
    image1: { type: ControlType.ResponsiveImage, title: "Plate 1" },
    image2: { type: ControlType.ResponsiveImage, title: "Plate 2" },
    image3: { type: ControlType.ResponsiveImage, title: "Plate 3" },
    image4: { type: ControlType.ResponsiveImage, title: "Plate 4" },
    sizeMode: { type: ControlType.Enum, title: "Size", options: ["px", "width"], optionTitles: ["Pixels", "Of width"], defaultValue: "px", displaySegmentedControl: true },
    fontSize: { type: ControlType.Number, title: "Font size", defaultValue: 56, min: 12, max: 240, unit: "px", step: 1, hidden: (p: Partial<PlateTextProps>) => String(p.sizeMode).toLowerCase().includes("width") },
    fitSize: { type: ControlType.Number, title: "Size of width", defaultValue: 30, min: 2, max: 60, unit: "%", step: 0.5, hidden: (p: Partial<PlateTextProps>) => !String(p.sizeMode).toLowerCase().includes("width") },
    fontWeight: { type: ControlType.Number, title: "Weight", defaultValue: 400, min: 300, max: 700, step: 100 },
    lineHeight: { type: ControlType.Number, title: "Line height", defaultValue: 1.08, min: 0.7, max: 2, step: 0.01 },
    tracking: { type: ControlType.Number, title: "Tracking", defaultValue: -0.04, min: -0.1, max: 0.2, step: 0.005, unit: "em" },
    align: { type: ControlType.Enum, title: "Align", options: ["left", "center", "right"], optionTitles: ["Left", "Center", "Right"], defaultValue: "left", displaySegmentedControl: true },
    plateSize: { type: ControlType.Number, title: "Plate size", defaultValue: 1, min: 0.3, max: 2, step: 0.01, unit: "em" },
    plateShift: { type: ControlType.Number, title: "Plate shift", defaultValue: 0.15, min: -1, max: 1, step: 0.01, unit: "em" },
    spin: { type: ControlType.Number, title: "Turn time", defaultValue: 40, min: 0, max: 120, unit: "s", step: 1, description: "Seconds per full turn. 0 keeps the plates still." },
    reveal: { type: ControlType.Boolean, title: "Reveal", defaultValue: true, description: "Words light up as the text scrolls into view." },
    color: { type: ControlType.Color, title: "Colour", defaultValue: "#1B1A17" },
    dimColor: { type: ControlType.Color, title: "Dim colour", defaultValue: "#A39C8C" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
})

In this preview

The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.