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