Layr Components

Components / Text

Quote Deck

One quote at a time in large type, names underneath with a line that fills while theirs shows; click a name to jump to it.

Props
6 s
44 px
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
400

Set in code: Quotes. The preview fills these in with demo content.

Wait for the next quote, or press a name.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Quote Deck shows up in the Insert menu, and its controls appear in the right panel.
// Quote Deck · Layr Components
//
// One quote at a time, set large. Names sit underneath, each with a line that
// fills while its quote is on show; press a name to jump to it. It moves on by
// itself, and waits while the pointer is over it or it is off screen.
// Paste into a new code file in Framer. Give it a width; its height follows.
// Add the quotes in the Quotes list.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { AnimatePresence, motion, useInView, useReducedMotion } from "framer-motion"
import { useEffect, useRef, useState, type CSSProperties } from "react"

interface QuoteDeckItem {
    quote: string
    name: string
    source: string
}

interface QuoteDeckProps {
    items: QuoteDeckItem[]
    autoplay: number
    quoteSize: number
    align: string
    textColor: string
    mutedColor: string
    lineColor: string
    accentColor: string
    fontFamily: string
    displayFamily: string
    displayWeight: number
    style?: CSSProperties
}

const ITEMS: QuoteDeckItem[] = [
    { quote: "Clear from the first email to the last, and finished a week early. We would book them again tomorrow.", name: "Sam K.", source: "Returning client" },
    { quote: "They listened more than they talked, and it shows in everything they made for us.", name: "Priya N.", source: "Studio owner" },
    { quote: "Every question got a straight answer the same day. That alone was worth it.", name: "Tom A.", source: "First project" },
    { quote: "Calm, careful and on time. The kind of team you hope to find and rarely do.", name: "Lena M.", source: "Written in March" },
]

const SAMPLE: QuoteDeckItem[] = [{ quote: "Add quotes and they will take turns here.", name: "Name", source: "Source" }]

const SHEET = `
.lyr-qd { container-type: inline-size; }
.lyr-qd .lyr-qd-quote { margin: 0; font-family: var(--lyr-qd-display); font-size: clamp(24px, 4.6cqw, var(--lyr-qd-size)); font-weight: var(--lyr-qd-weight); line-height: 1.16; letter-spacing: -0.03em; text-wrap: balance; text-align: var(--lyr-qd-align); max-width: 22em; margin-inline: var(--lyr-qd-inline); }
.lyr-qd .lyr-qd-stage { display: grid; min-height: calc(clamp(24px, 4.6cqw, var(--lyr-qd-size)) * 1.16 * 3.2); align-content: start; }
.lyr-qd .lyr-qd-stage > * { grid-area: 1 / 1; }
.lyr-qd .lyr-qd-names { display: grid; grid-template-columns: repeat(var(--lyr-qd-count), minmax(0, 1fr)); gap: clamp(10px, 2cqw, 28px); margin-top: clamp(28px, 5cqw, 56px); }
.lyr-qd .lyr-qd-name { all: unset; cursor: pointer; display: block; padding-top: 16px; position: relative; font-size: 15px; line-height: 1.35; color: var(--lyr-qd-muted); transition: color .3s ease; min-width: 0; }
.lyr-qd .lyr-qd-name b { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lyr-qd .lyr-qd-name span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lyr-qd .lyr-qd-name[aria-current="true"], .lyr-qd .lyr-qd-name:hover { color: var(--lyr-qd-text); }
.lyr-qd .lyr-qd-name::before { content: ""; position: absolute; left: 0; right: 0; top: 0; height: 1px; background: var(--lyr-qd-line); }
.lyr-qd .lyr-qd-fill { position: absolute; left: 0; top: 0; height: 1px; background: var(--lyr-qd-accent); width: 100%; transform-origin: 0 50%; transform: scaleX(0); }
.lyr-qd .lyr-qd-name[aria-current="true"] .lyr-qd-fill { transform: scaleX(1); }
.lyr-qd .lyr-qd-name[aria-current="true"] .lyr-qd-fill.lyr-qd-run { animation: lyr-qd-fill var(--lyr-qd-time) linear both; }
.lyr-qd .lyr-qd-name:focus-visible { outline: 2px solid var(--lyr-qd-accent); outline-offset: 4px; }
@keyframes lyr-qd-fill { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@container (max-width: 560px) { .lyr-qd .lyr-qd-names { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 18px; } }
`

/**
 * @framerIntrinsicWidth 1100
 * @framerIntrinsicHeight 420
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function QuoteDeck(props: QuoteDeckProps) {
    const {
        items = ITEMS,
        autoplay = 6,
        quoteSize = 44,
        align = "left",
        textColor = "#1B1A17",
        mutedColor = "#8A8375",
        lineColor = "rgba(27,26,23,0.14)",
        accentColor = "#1B1A17",
        fontFamily = "Inter",
        displayFamily = "",
        displayWeight = 400,
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const reduced = useReducedMotion()
    const root = useRef<HTMLDivElement>(null)
    const inView = useInView(root, { amount: 0.3 })
    const [at, setAt] = useState(0)
    const [held, setHeld] = useState(false)

    const given = (Array.isArray(items) ? items : []).filter((q) => q && (q.quote || q.name))
    const quotes = given.length ? given : SAMPLE
    const index = Math.min(at, quotes.length - 1)
    const running = !isStatic && !reduced && autoplay > 0 && inView && !held && quotes.length > 1

    // One timer per quote: it restarts whenever the quote changes or the deck is paused and resumed.
    useEffect(() => {
        if (!running) return
        const id = window.setTimeout(() => setAt((i) => (Math.min(i, quotes.length - 1) + 1) % quotes.length), autoplay * 1000)
        return () => window.clearTimeout(id)
    }, [running, index, autoplay, quotes.length])

    const centered = String(align).toLowerCase().includes("cent")
    const q = quotes[index]
    const family = `"${fontFamily}", "Inter", system-ui, sans-serif`

    return (
        <div
            ref={root}
            className="lyr-qd"
            onPointerEnter={() => setHeld(true)}
            onPointerLeave={() => setHeld(false)}
            style={{ ...style, position: "relative", width: "100%", fontFamily: family, color: textColor, "--lyr-qd-size": `${quoteSize}px`, "--lyr-qd-display": displayFamily ? `"${displayFamily}", ${family}` : "inherit", "--lyr-qd-weight": displayWeight, "--lyr-qd-align": centered ? "center" : "left", "--lyr-qd-inline": centered ? "auto" : "0", "--lyr-qd-count": quotes.length, "--lyr-qd-muted": mutedColor, "--lyr-qd-text": textColor, "--lyr-qd-line": lineColor, "--lyr-qd-accent": accentColor, "--lyr-qd-time": `${autoplay}s` } as CSSProperties}
        >
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            <div className="lyr-qd-stage" aria-live="polite">
                <AnimatePresence initial={false}>
                    <motion.blockquote
                        key={`${index}-${q.quote}`}
                        className="lyr-qd-quote"
                        initial={isStatic || reduced ? false : { opacity: 0, y: 18, filter: "blur(6px)" }}
                        animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                        exit={{ opacity: 0, y: -12, filter: "blur(6px)", transition: { duration: 0.3 } }}
                        transition={{ duration: 0.6, ease: [0.2, 0.8, 0.2, 1], delay: 0.12 }}
                    >
                        {"“"}
                        {q.quote}
                        {"”"}
                    </motion.blockquote>
                </AnimatePresence>
            </div>
            <div className="lyr-qd-names">
                {quotes.map((n, i) => (
                    <button key={i} type="button" className="lyr-qd-name" aria-current={i === index} onClick={() => setAt(i)}>
                        <i className={`lyr-qd-fill${running && i === index ? " lyr-qd-run" : ""}`} key={i === index ? `on-${index}-${running}` : "off"} />
                        <b>{n.name}</b>
                        <span>{n.source}</span>
                    </button>
                ))}
            </div>
        </div>
    )
}

addPropertyControls(QuoteDeck, {
    items: {
        type: ControlType.Array,
        title: "Quotes",
        maxCount: 8,
        control: {
            type: ControlType.Object,
            controls: {
                quote: { type: ControlType.String, title: "Quote", defaultValue: "", displayTextArea: true },
                name: { type: ControlType.String, title: "Name", defaultValue: "" },
                source: { type: ControlType.String, title: "Source", defaultValue: "", description: "A line under the name: a role, a place, a date." },
            },
        },
        defaultValue: ITEMS,
    },
    autoplay: { type: ControlType.Number, title: "Each stays", defaultValue: 6, min: 0, max: 20, unit: "s", step: 0.5, description: "0 switches auto-advance off." },
    quoteSize: { type: ControlType.Number, title: "Quote size", defaultValue: 44, min: 20, max: 80, unit: "px", step: 1 },
    align: { type: ControlType.Enum, title: "Align", options: ["left", "center"], optionTitles: ["Left", "Center"], defaultValue: "left", displaySegmentedControl: true },
    textColor: { type: ControlType.Color, title: "Text", defaultValue: "#1B1A17" },
    mutedColor: { type: ControlType.Color, title: "Muted text", defaultValue: "#8A8375" },
    lineColor: { type: ControlType.Color, title: "Lines", defaultValue: "rgba(27,26,23,0.14)" },
    accentColor: { type: ControlType.Color, title: "Progress", defaultValue: "#1B1A17" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
    displayFamily: { type: ControlType.String, title: "Display font", defaultValue: "", placeholder: "Same as font family", description: "Family for the large type. Leave empty to use the font family." },
    displayWeight: { type: ControlType.Number, title: "Display weight", defaultValue: 400, min: 300, max: 800, step: 100 },
})

In this preview

No pictures needed.