Layr Components

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
76 px
30%
40 px
400
-2%
#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.