Layr Components

Components / Cards and lists

Review Deck

Reviews stacked as a deck of cards: click the front card or Next to flick it to the back, Back brings the last one in again.

Props
380 px
76%
#0E0E0D
#6E6E6B
#F5F4F1
#E6E5E1
#FFFFFF

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

Click the front card, or use Next and Back.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Review Deck shows up in the Insert menu, and its controls appear in the right panel.
// Review Deck · Layr Components
//
// Reviews stacked as a deck of cards, the cards behind peeking out to the
// left. Click the front card, or Next, and it flicks away to the right and
// goes to the back of the deck; Back brings the last one in again. A count
// under the deck says which review is in front.
// Paste into a new code file in Framer. Give it the width the deck may take; its height follows Card size. Add one item per review.

import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { useEffect, useRef, useState, type CSSProperties } from "react"

type Review = { quote?: string; who?: string }

interface ReviewDeckProps {
    items: Review[]
    cardHeight: number
    cardShare: number
    nextLabel: string
    backLabel: string
    fontFamily: string
    textColour: string
    softColour: string
    cardColour: string
    lineColour: string
    pageColour: string
    style?: CSSProperties
}

const REVIEWS: Review[] = [
    { quote: "It does one thing, and it does it before I have finished my coffee.", who: "Night-shift nurse" },
    { quote: "I stopped dreading the end of the month. That is the whole review.", who: "Teacher, two children" },
    { quote: "Set it up on a Sunday and have not had to think about it since.", who: "Final-year student" },
    { quote: "The first app I have kept using past the second week.", who: "Freelance designer" },
]

const BEHIND = 3 // cards that stay visible behind the front one
const RATIO = 0.93 // each card behind is this much smaller

const SHEET = `
.lyr-rvd{position:relative;width:100%;height:calc(var(--h) + 64px);color:var(--ink);-webkit-font-smoothing:antialiased}
.lyr-rvd-card{position:absolute;right:0;top:0;margin:0;appearance:none;-webkit-appearance:none;width:calc(100% * var(--share) / 100);height:var(--h);box-sizing:border-box;padding:36px 36px 30px;border-radius:28px;background:var(--card);border:1px solid var(--line);box-shadow:0 24px 48px -34px rgba(12,14,17,.14);display:flex;flex-direction:column;justify-content:space-between;text-align:left;font:inherit;color:inherit;cursor:pointer;transform-origin:0 50%;transform:translate3d(calc((100% * 100 / var(--share) - 100%) * -1 * var(--f,0) + 60% * var(--out,0)),0,0) scale(var(--s,1));opacity:var(--o,1);transition:transform .6s cubic-bezier(.2,.75,.2,1),opacity .35s ease;-webkit-tap-highlight-color:transparent}
.lyr-rvd-card:focus-visible{outline:2px solid var(--ink);outline-offset:3px}
.lyr-rvd-quote{display:block;font-size:32px;line-height:1.14;letter-spacing:-0.04em;margin:0;text-wrap:pretty;transition:opacity .3s ease}
.lyr-rvd-who{display:block;font-size:15px;line-height:1.3;letter-spacing:-0.012em;color:var(--soft);margin:0;transition:opacity .3s ease}
.lyr-rvd-back .lyr-rvd-quote,.lyr-rvd-back .lyr-rvd-who{opacity:0}
.lyr-rvd-dim{position:absolute;inset:0;border-radius:inherit;background:var(--page);opacity:var(--dim,0);transition:opacity .5s ease;pointer-events:none}
.lyr-rvd-card.lyr-rvd-jump{transition:none}
.lyr-rvd-bar{position:absolute;right:0;bottom:0;width:calc(100% * var(--share) / 100);height:40px;display:flex;align-items:center;justify-content:space-between;font-size:15px;letter-spacing:-0.012em}
.lyr-rvd-bar span{color:var(--soft);font-variant-numeric:tabular-nums}
.lyr-rvd-btns{display:flex;gap:22px}
.lyr-rvd-btn{appearance:none;-webkit-appearance:none;background:none;border:0;padding:8px 0;margin:0;font:inherit;color:inherit;cursor:pointer;display:inline-flex;align-items:center;gap:8px;transition:opacity .2s}
.lyr-rvd-btn:hover{opacity:.62}
.lyr-rvd-btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
@media (max-width:809.98px){.lyr-rvd-card{padding:26px 24px 22px;border-radius:24px}.lyr-rvd-quote{font-size:23px}.lyr-rvd-who{font-size:14px}}
@media (min-width:810px) and (max-width:1199.98px){.lyr-rvd-quote{font-size:27px}}
@media (prefers-reduced-motion:reduce){.lyr-rvd-card,.lyr-rvd-quote,.lyr-rvd-who,.lyr-rvd-dim{transition:none}}
`

// Where a card sits for its place in the deck (k = 0 is the front).
function pose(k: number) {
    const kk = Math.min(k, BEHIND)
    return { f: (1 - Math.pow(RATIO, kk)) / (1 - Math.pow(RATIO, BEHIND)), s: Math.pow(RATIO, kk), o: k > BEHIND ? 0 : 1, dim: Math.min(0.7, 0.24 * k) }
}

// Puts a card at place k in a deck of n: out = 1 is off to the right, o overrides its opacity.
function place(el: HTMLElement | null, k: number, out: number, n: number, o?: number) {
    if (!el) return
    const p = pose(k)
    el.style.setProperty("--f", p.f.toFixed(4))
    el.style.setProperty("--s", p.s.toFixed(4))
    el.style.setProperty("--out", String(out))
    el.style.setProperty("--o", String(o === undefined ? p.o : o))
    el.style.setProperty("--dim", p.dim.toFixed(3))
    // the numbers stay small so the deck never climbs over a fixed navigation
    el.style.zIndex = String(n - k)
    el.tabIndex = k === 0 ? 0 : -1
    el.classList.toggle("lyr-rvd-back", k !== 0)
}

// Moves card idx from its place when `from` was in front to its place now that `now` is.
function paint(el: HTMLElement | null, idx: number, now: number, from: number, n: number, timers: number[]) {
    const k = (idx - now + n) % n
    const was = (idx - from + n) % n
    window.clearTimeout(timers[idx])
    if (!el) return
    if (was === 0 && k === n - 1) {
        // flicked away: off to the right, above the others, then quietly to the back of the deck
        place(el, 0, 1, n, 0)
        el.style.zIndex = String(n + 1)
        timers[idx] = window.setTimeout(() => {
            el.classList.add("lyr-rvd-jump")
            place(el, k, 0, n, 0)
            void el.offsetWidth
            el.classList.remove("lyr-rvd-jump")
            place(el, k, 0, n)
        }, 380)
    } else if (was === n - 1 && k === 0) {
        // brought back: in from the right
        el.classList.add("lyr-rvd-jump")
        place(el, 0, 1, n, 0)
        void el.offsetWidth
        el.classList.remove("lyr-rvd-jump")
        place(el, 0, 0, n)
    } else place(el, k, 0, n)
}

/**
 * @framerIntrinsicWidth 660
 * @framerIntrinsicHeight 440
 *
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight auto
 */
export default function ReviewDeck(props: ReviewDeckProps) {
    const {
        items = REVIEWS,
        cardHeight = 380,
        cardShare = 76,
        nextLabel = "Next",
        backLabel = "Back",
        fontFamily = "Inter",
        textColour = "#0C0E11",
        softColour = "#5C6168",
        cardColour = "#FFFFFF",
        lineColour = "#EFEFEC",
        pageColour = "#FFFFFF",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const reviews = (items || []).filter((r) => r && ((r.quote || "").trim() || (r.who || "").trim()))
    const n = reviews.length
    const cards = useRef<Array<HTMLButtonElement | null>>([])
    const timers = useRef<number[]>([])
    const active = useRef(0)
    const [front, setFront] = useState(0)

    const go = (dir: number) => {
        if (isStatic || n < 2) return
        const from = active.current
        active.current = (from + dir + n) % n
        for (let i = 0; i < n; i++) paint(cards.current[i], i, active.current, from, n, timers.current)
        setFront(active.current)
    }

    // Lay the deck out again when the number of reviews changes.
    useEffect(() => {
        if (isStatic || !n) return
        active.current = Math.min(active.current, n - 1)
        const list = cards.current
        const pending = timers.current
        for (let i = 0; i < n; i++) place(list[i], (i - active.current + n) % n, 0, n)
        return () => {
            for (const t of pending) window.clearTimeout(t)
            for (const el of list) el?.classList.remove("lyr-rvd-jump")
        }
    }, [isStatic, n])

    const vars = {
        "--h": `${cardHeight}px`,
        "--share": cardShare,
        "--ink": textColour,
        "--soft": softColour,
        "--card": cardColour,
        "--line": lineColour,
        "--page": pageColour,
    } as CSSProperties
    const shown = Math.min(front, Math.max(0, n - 1))

    return (
        <div className="lyr-rvd" style={{ ...style, ...vars, fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif` }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            {reviews.map((r, idx) => {
                const start = pose(idx)
                return (
                    <button
                        key={idx}
                        ref={(el) => {
                            cards.current[idx] = el
                        }}
                        type="button"
                        className={`lyr-rvd-card${idx === 0 ? "" : " lyr-rvd-back"}`}
                        tabIndex={idx === 0 ? 0 : -1}
                        style={{ "--f": start.f.toFixed(4), "--s": start.s.toFixed(4), "--o": start.o, "--dim": start.dim.toFixed(3), zIndex: n - idx } as CSSProperties}
                        onClick={() => go(1)}
                        aria-label={`${r.quote || ""} ${r.who || ""}. ${nextLabel}`}
                    >
                        <span className="lyr-rvd-quote">{r.quote}</span>
                        <span className="lyr-rvd-who">{r.who}</span>
                        <span className="lyr-rvd-dim" />
                    </button>
                )
            })}
            {n > 1 ? (
                <div className="lyr-rvd-bar">
                    <span>
                        {shown + 1} of {n}
                    </span>
                    <div className="lyr-rvd-btns">
                        <button type="button" className="lyr-rvd-btn" onClick={() => go(-1)}>
                            <svg viewBox="0 0 14 14" aria-hidden="true">
                                <path d="M12.5 7h-11M6 2.5 1.5 7 6 11.5" />
                            </svg>
                            {backLabel}
                        </button>
                        <button type="button" className="lyr-rvd-btn" onClick={() => go(1)}>
                            {nextLabel}
                            <svg viewBox="0 0 14 14" aria-hidden="true">
                                <path d="M1.5 7h11M8 2.5 12.5 7 8 11.5" />
                            </svg>
                        </button>
                    </div>
                </div>
            ) : null}
        </div>
    )
}

addPropertyControls(ReviewDeck, {
    items: {
        type: ControlType.Array,
        title: "Reviews",
        maxCount: 12,
        description: "One card per review, front card first.",
        control: {
            type: ControlType.Object,
            controls: {
                quote: { type: ControlType.String, title: "Quote", defaultValue: "", displayTextArea: true },
                who: { type: ControlType.String, title: "Who", defaultValue: "", description: "Name and a few words about them." },
            },
        },
        defaultValue: [
            { quote: "It does one thing, and it does it before I have finished my coffee.", who: "Night-shift nurse" },
            { quote: "I stopped dreading the end of the month. That is the whole review.", who: "Teacher, two children" },
            { quote: "Set it up on a Sunday and have not had to think about it since.", who: "Final-year student" },
            { quote: "The first app I have kept using past the second week.", who: "Freelance designer" },
        ],
    },
    cardHeight: { type: ControlType.Number, title: "Card size", defaultValue: 380, min: 200, max: 700, step: 10, unit: "px", description: "How tall every card is. Raise it if your longest quote does not fit." },
    cardShare: { type: ControlType.Number, title: "Card share", defaultValue: 76, min: 50, max: 100, step: 1, unit: "%", description: "How much of the width the front card takes. The rest shows the cards behind." },
    nextLabel: { type: ControlType.String, title: "Next label", defaultValue: "Next" },
    backLabel: { type: ControlType.String, title: "Back label", defaultValue: "Back" },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "A font already used on the page." },
    textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#0C0E11" },
    softColour: { type: ControlType.Color, title: "Soft colour", defaultValue: "#5C6168" },
    cardColour: { type: ControlType.Color, title: "Card colour", defaultValue: "#FFFFFF" },
    lineColour: { type: ControlType.Color, title: "Line colour", defaultValue: "#EFEFEC" },
    pageColour: { type: ControlType.Color, title: "Page colour", defaultValue: "#FFFFFF", description: "Cards behind fade toward this colour." },
})

In this preview

No pictures needed.