Components / Cards and lists
Quote Row
Names on hairline rows; point at, tap or tab to a name to show that person's words in large type beside the list, or under it on phones.
Props
#0E0E0D
#6E6E6B
#E6E5E1
Set in code: Quotes. The preview fills these in with demo content.
Point at a name, or tap it.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Quote Row shows up in the Insert menu, and its controls appear in the right panel.
// Quote Row · Layr Components
//
// Names on hairline rows. Pointing at a name, tapping it or tabbing to it shows
// that person's words in large type beside the list; the first is shown on load.
// With Stacked on (phones) the words open under the name instead.
// Paste into a new code file in Framer. Place it as wide as the section; it sets
// its own height. Turn Stacked on at the phone breakpoint.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"
type Words = { quote?: string; name?: string; role?: string }
interface QuoteRowProps {
items: Words[]
stacked: boolean
rowHeight: number
nameColumn: number
quoteSize: number
fontFamily: string
quoteFont: string
quoteWeight: number
quoteTracking: number
textColour: string
mutedColour: string
lineColour: string
style?: CSSProperties
}
const ITEMS: Words[] = [
{ quote: "I had a plan within a week and a price before anything started. Nothing changed after that.", name: "Priya N.", role: "Shop owner" },
{ quote: "Every letter was explained in plain words. We always knew what came next.", name: "Tom and Ellen W.", role: "First-time buyers" },
{ quote: "They answered the phone themselves. That alone was worth it.", name: "Aiko M.", role: "Nurse" },
{ quote: "Clear about what it would cost, and it cost exactly that.", name: "Daniel O.", role: "Teacher" },
]
const SHEET = `
.lyr-qr{position:relative;width:100%;-webkit-font-smoothing:antialiased}
.lyr-qr-row{appearance:none;-webkit-appearance:none;display:flex;flex-direction:column;justify-content:center;gap:2px;width:var(--lyr-qr-col);height:var(--lyr-qr-row);margin:0;padding:0;border:0;border-top:1px solid var(--lyr-qr-line);background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;opacity:.5;transition:opacity .25s ease}
.lyr-qr-item[data-on="1"] .lyr-qr-row{opacity:1}
.lyr-qr-name{font-size:17px;line-height:1.3;font-weight:500;letter-spacing:-.012em}
.lyr-qr-role{font-size:14px;line-height:1.35;letter-spacing:-.006em;color:var(--lyr-qr-muted)}
.lyr-qr-last .lyr-qr-row{border-bottom:1px solid var(--lyr-qr-line)}
.lyr-qr-win{position:absolute;left:calc(var(--lyr-qr-col) + var(--lyr-qr-gap));right:0;top:0;height:calc(var(--lyr-qr-n) * var(--lyr-qr-row));display:flex;align-items:flex-start;pointer-events:none;opacity:0;transform:translate3d(0,10px,0);transition:opacity .16s ease,transform 0s linear .16s}
.lyr-qr-item[data-on="1"] .lyr-qr-win{opacity:1;transform:none;transition:opacity .4s ease .14s,transform .6s cubic-bezier(.2,.7,.2,1) .14s}
.lyr-qr-quote{margin:0;font-size:var(--lyr-qr-size);line-height:1.16;font-weight:var(--lyr-qr-qw);letter-spacing:calc(var(--lyr-qr-qt) * 1em);text-wrap:balance;max-width:21em}
.lyr-qr-stack .lyr-qr-row{width:100%;height:auto;min-height:var(--lyr-qr-row);padding:12px 0}
.lyr-qr-stack .lyr-qr-win{position:static;height:auto;display:none;padding:4px 0 26px;transform:none}
.lyr-qr-stack .lyr-qr-item[data-on="1"] .lyr-qr-win{display:block;opacity:1}
@media (hover:hover){.lyr-qr-row:hover{opacity:1}}
@media (prefers-reduced-motion:reduce){.lyr-qr *{transition:none!important}}
`
/**
* @framerIntrinsicWidth 1120
* @framerIntrinsicHeight 304
*
* @framerSupportedLayoutWidth any
* @framerSupportedLayoutHeight auto
*/
export default function QuoteRow(props: QuoteRowProps) {
const {
items = ITEMS,
stacked = false,
rowHeight = 76,
nameColumn = 30,
quoteSize = 40,
fontFamily = "Inter",
quoteFont = "",
quoteWeight = 400,
quoteTracking = -2,
textColour = "#F2EFE2",
mutedColour = "#86A39B",
lineColour = "#1B3F3A",
style,
} = props
const isStatic = useIsStaticRenderer()
const [active, setActive] = useState(0)
const canHover = useRef(false)
useEffect(() => {
canHover.current = !!(window.matchMedia && window.matchMedia("(hover: hover)").matches)
}, [])
const list = Array.isArray(items) ? items.filter(Boolean) : []
const n = Math.max(1, list.length)
const choose = (k: number) => {
if (!isStatic) setActive(k)
}
const family = `"${String(fontFamily || "Inter").replace(/['"]/g, "")}", "Inter", system-ui, sans-serif`
const vars = {
"--lyr-qr-n": n,
"--lyr-qr-row": `${Math.max(44, Number(rowHeight) || 76)}px`,
"--lyr-qr-col": `${Math.min(70, Math.max(18, Number(nameColumn) || 30))}%`,
"--lyr-qr-gap": "40px",
"--lyr-qr-size": `${Math.max(16, Number(quoteSize) || 40)}px`,
"--lyr-qr-qw": Math.round(Number(quoteWeight) || 400),
"--lyr-qr-qt": (Number(quoteTracking) || 0) / 100,
"--lyr-qr-muted": mutedColour,
"--lyr-qr-line": lineColour,
} as CSSProperties
const quoteFamily = quoteFont ? `"${String(quoteFont).replace(/['"]/g, "")}", ${family}` : undefined
return (
<div className={"lyr-qr" + (stacked ? " lyr-qr-stack" : "")} style={{ ...style, ...vars, color: textColour, fontFamily: family }}>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
{list.map((it, k) => {
const on = active === k
const words = String(it.quote || "").trim()
return (
<div key={k} className={"lyr-qr-item" + (k === n - 1 ? " lyr-qr-last" : "")} data-on={on ? "1" : "0"}>
<button
type="button"
className="lyr-qr-row"
aria-pressed={on}
onClick={() => choose(k)}
onFocus={() => choose(k)}
onPointerEnter={() => {
if (canHover.current && !stacked) choose(k)
}}
>
<span className="lyr-qr-name">{it.name}</span>
{it.role ? <span className="lyr-qr-role">{it.role}</span> : null}
</button>
<div className="lyr-qr-win" aria-hidden={on ? "false" : "true"}>
<p className="lyr-qr-quote" style={quoteFamily ? { fontFamily: quoteFamily } : undefined}>
{words ? `“${words}”` : ""}
</p>
</div>
</div>
)
})}
</div>
)
}
addPropertyControls(QuoteRow, {
items: {
type: ControlType.Array,
title: "Quotes",
maxCount: 24,
control: {
type: ControlType.Object,
controls: {
quote: { type: ControlType.String, title: "Quote", displayTextArea: true, defaultValue: "", description: "The words, without quotation marks." },
name: { type: ControlType.String, title: "Name", defaultValue: "Name" },
role: { type: ControlType.String, title: "Role", defaultValue: "", description: "A second line under the name: what they do, or where." },
},
},
defaultValue: ITEMS,
},
stacked: { type: ControlType.Boolean, title: "Stacked", defaultValue: false, description: "On for phones: the words open under the name." },
rowHeight: { type: ControlType.Number, title: "Row height", min: 44, max: 160, step: 2, unit: "px", defaultValue: 76 },
nameColumn: { type: ControlType.Number, title: "Name column", min: 18, max: 70, step: 1, unit: "%", defaultValue: 30, description: "Width of the names; the words take the rest." },
quoteSize: { type: ControlType.Number, title: "Quote size", min: 16, max: 72, step: 1, unit: "px", defaultValue: 40 },
fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the text styles for names." },
quoteFont: { type: ControlType.String, title: "Quote font", defaultValue: "", placeholder: "Same as font", description: "The family name used for the words. Leave empty to use the font." },
quoteWeight: { type: ControlType.Number, title: "Quote weight", min: 100, max: 900, step: 100, defaultValue: 400 },
quoteTracking: { type: ControlType.Number, title: "Quote spacing", min: -6, max: 4, step: 0.5, unit: "%", defaultValue: -2 },
textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#F2EFE2" },
mutedColour: { type: ControlType.Color, title: "Muted colour", defaultValue: "#86A39B" },
lineColour: { type: ControlType.Color, title: "Line colour", defaultValue: "#1B3F3A" },
})
In this preview
No pictures needed.