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
#0E0E0D
#6E6E6B
#E6E5E1
#FF4A1C
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.