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
#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.