Components / Hero
Shelf
Rows of product covers drift on a tilted plane that lies flat as you scroll, with look tabs that regrade every cover at once.
Scroll insideFull screen
Props
#0E0E0D
#FFFFFF
#E6E5E1
#6E6E6B
Set in code: Covers, Looks. The preview fills these in with demo content.
Scroll inside the preview, or pick a look.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Shelf shows up in the Insert menu, and its controls appear in the right panel.
// Shelf · Layr Components
//
// Rows of product covers drift sideways on a tilted plane, like a shelf seen from
// above. As the page scrolls the plane lies flat and settles. An optional row of
// look tabs regrades every cover at once (warm film, night, mono, fade), and the
// rows pause while the pointer is over them.
// Paste into a new code file in Framer and give it a fixed size near the top of a
// page: the tabs sit on top and the shelf fills the rest. It flattens over the
// scroll it takes to bring the shelf to the middle of the window. A cover without
// a picture shows a plain tone.
import { addPropertyControls, ControlType, RenderTarget } from "framer"
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion"
import { useEffect, useId, useRef, useState, type CSSProperties } from "react"
type Img = { src: string; srcSet?: string; alt?: string }
type Item = { image?: Img; title?: string; meta?: string; link?: string }
type Look = { name?: string; style?: string }
interface ShelfProps {
font: string
items: Item[]
rows: number
cardWidth: number
ratio: number
gap: number
radius: number
speed: number
tilt: number
lift: number
flattenOnScroll: boolean
showLabels: boolean
labelSize: number
fadeEdges: boolean
pauseOnHover: boolean
switcher: boolean
prompt: string
originalLabel: string
looks: Look[]
chipScale: number
ink: string
onInk: string
line: string
muted: string
style?: CSSProperties
}
const FONT = 'var(--shelf-font, "Inter"), "Inter", system-ui, -apple-system, sans-serif'
const fontVar = (f?: string) => (!f ? undefined : /[,"']/.test(f) ? f : JSON.stringify(f))
type Recipe = { filter: string; tint?: string; blend?: CSSProperties["mixBlendMode"] }
const RECIPES: Record<string, Recipe> = {
warm: {
filter: "contrast(1.08) saturate(1.2) sepia(0.2) brightness(1.03)",
tint: "linear-gradient(180deg, rgba(255,170,90,.16), rgba(120,60,20,.12))",
blend: "soft-light",
},
night: {
filter: "contrast(1.16) saturate(0.92) hue-rotate(-14deg) brightness(0.93)",
tint: "linear-gradient(180deg, rgba(0,120,140,.34), rgba(0,40,70,.4))",
blend: "soft-light",
},
mono: { filter: "grayscale(1) contrast(1.24) brightness(1.02)" },
fade: {
filter: "contrast(0.9) saturate(0.8) brightness(1.1)",
tint: "linear-gradient(180deg, rgba(255,232,214,.2), rgba(190,214,226,.16))",
blend: "screen",
},
none: { filter: "none" },
}
function recipeFor(style?: string): Recipe {
const s = String(style || "").toLowerCase()
if (s.includes("night") || s.includes("cool")) return RECIPES.night
if (s.includes("mono") || s.includes("black")) return RECIPES.mono
if (s.includes("fade") || s.includes("pastel")) return RECIPES.fade
if (s.includes("none")) return RECIPES.none
return RECIPES.warm
}
const DEFAULT_LOOKS: Look[] = [
{ name: "Warm film", style: "Warm film" },
{ name: "Night", style: "Night teal" },
{ name: "Mono", style: "Mono" },
{ name: "Soft fade", style: "Fade" },
]
const ITEMS: Item[] = [
{ title: "Golden Hour", meta: "24 presets" },
{ title: "Night City", meta: "16 LUTs" },
{ title: "Paper Grain", meta: "40 textures" },
{ title: "Room Tone", meta: "120 sounds" },
{ title: "Coastal", meta: "20 presets" },
{ title: "Studio Mono", meta: "12 presets" },
]
// Shown on a cover without a picture.
const EMPTY = [
"linear-gradient(160deg, #3a3a36, #1c1c1a)",
"linear-gradient(160deg, #a8634a, #5b3324)",
"linear-gradient(160deg, #d9d4c7, #9c968a)",
"linear-gradient(160deg, #46527a, #232a42)",
"linear-gradient(160deg, #c9a466, #7a6136)",
"linear-gradient(160deg, #56655a, #2a332c)",
]
function rotate<T>(list: T[], by: number): T[] {
if (!list.length) return list
const n = ((by % list.length) + list.length) % list.length
return list.slice(n).concat(list.slice(0, n))
}
type Slot = { item: Item; index: number }
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 700
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight fixed
*/
export default function Shelf(props: ShelfProps) {
const {
font = "Inter",
items = ITEMS,
rows = 2,
cardWidth = 232,
ratio = 1.25,
gap = 18,
radius = 16,
speed = 28,
tilt = 44,
lift = 14,
flattenOnScroll = true,
showLabels = true,
labelSize = 17,
fadeEdges = true,
pauseOnHover = true,
switcher = true,
prompt = "Preview a look",
originalLabel = "Original",
looks = DEFAULT_LOOKS,
chipScale = 1,
ink = "#F2F1EC",
onInk = "#141413",
line = "rgba(242,241,236,.22)",
muted = "#8E8C85",
style,
} = props
const lookList = looks && looks.length ? looks : DEFAULT_LOOKS
const [look, setLook] = useState(-1)
const recipe: Recipe = look >= 0 && lookList[look] ? recipeFor(lookList[look].style) : RECIPES.none
const cs = (n: number) => Math.round(n * chipScale)
const chipH = cs(40)
const isCanvas = RenderTarget.current() === RenderTarget.canvas
const reduced = useReducedMotion()
const root = useRef<HTMLDivElement>(null)
// Scroll progress: 0 at the top of the page, 1 once the shelf has
// travelled to the middle of the viewport.
const progress = useMotionValue(0)
useEffect(() => {
if (isCanvas || reduced || !flattenOnScroll) {
progress.set(0)
return
}
let distance = 400
const update = () => {
const p = Math.min(1, Math.max(0, window.scrollY / distance))
progress.set(p)
}
const measure = () => {
const el = root.current
if (!el) return
const rect = el.getBoundingClientRect()
const docTop = rect.top + window.scrollY
const target = (window.innerHeight - rect.height) / 2
distance = Math.max(240, docTop - Math.max(target, 0))
update()
}
measure()
window.addEventListener("scroll", update, { passive: true })
window.addEventListener("resize", measure)
return () => {
window.removeEventListener("scroll", update)
window.removeEventListener("resize", measure)
}
}, [isCanvas, reduced, flattenOnScroll, progress])
const smooth = useSpring(progress, { stiffness: 140, damping: 26, mass: 0.5 })
const startTilt = reduced ? 0 : tilt
const rotateX = useTransform(smooth, [0, 1], [startTilt, 0])
const scale = useTransform(smooth, [0, 1], [1 + lift / 100, 1])
const y = useTransform(smooth, [0, 1], [startTilt ? 24 : 0, 0])
const source = (items || []).filter((it) => it && (it.title || it.image?.src))
const cardHeight = Math.round(cardWidth * ratio)
const rowCount = Math.max(1, Math.min(3, Math.round(rows)))
// Repeat the set until one copy is comfortably wider than any screen.
const minCards = Math.ceil(2800 / (cardWidth + gap))
const buildRow = (index: number): Slot[] => {
if (!source.length) return []
const slots = source.map((item, i) => ({ item, index: i }))
const shifted = rotate(slots, Math.round((source.length / rowCount) * index))
const out: Slot[] = []
while (out.length < Math.max(minCards, shifted.length)) out.push(...shifted)
return out
}
const uid = "lyr-shelf-" + useId().replace(/[^a-zA-Z0-9_-]/g, "")
const edge = fadeEdges ? "linear-gradient(90deg, transparent 0%, #000 12%, #000 88%, transparent 100%)" : undefined
const chip = (on: boolean): CSSProperties => ({
flex: "0 0 auto",
height: chipH,
padding: `0 ${cs(17)}px`,
borderRadius: chipH,
border: `1px solid ${on ? ink : line}`,
background: on ? ink : "transparent",
color: on ? onInk : ink,
fontFamily: FONT,
fontSize: cs(15),
fontWeight: 500,
letterSpacing: "-0.01em",
cursor: "pointer",
whiteSpace: "nowrap",
transition: "background .25s ease, color .25s ease, border-color .25s ease",
})
const sheet = `
@keyframes ${uid}-a { from { transform: translate3d(0,0,0) } to { transform: translate3d(-50%,0,0) } }
@keyframes ${uid}-b { from { transform: translate3d(-50%,0,0) } to { transform: translate3d(0,0,0) } }
.${uid}-row { display:flex; width:max-content; will-change:transform; }
${pauseOnHover ? `.${uid}-plane:hover .${uid}-row { animation-play-state: paused; }` : ""}
.${uid}-link { transition: opacity .25s ease; }
.${uid}-link:hover { opacity: .78; }
@media (prefers-reduced-motion: reduce) { .${uid}-row { animation: none !important; } }
`
return (
<div
style={
{
...style,
position: "relative",
width: "100%",
height: "100%",
display: "flex",
"--shelf-font": fontVar(font),
flexDirection: "column",
gap: cs(28),
} as CSSProperties
}
>
<style dangerouslySetInnerHTML={{ __html: sheet }} />
{switcher ? (
<div
role="group"
aria-label={prompt || "Preview a look"}
style={{
flex: "0 0 auto",
display: "flex",
alignItems: "center",
justifyContent: "safe center",
gap: cs(8),
padding: `0 ${cs(20)}px`,
overflowX: "auto",
scrollbarWidth: "none",
}}
>
{prompt ? (
<span style={{ flex: "0 0 auto", fontFamily: FONT, fontSize: cs(14), fontWeight: 400, color: muted, marginRight: cs(6), whiteSpace: "nowrap" }}>{prompt}</span>
) : null}
<button type="button" aria-pressed={look === -1} onClick={() => setLook(-1)} style={chip(look === -1)}>
{originalLabel}
</button>
{lookList.map((l, i) => (
<button key={i} type="button" aria-pressed={look === i} onClick={() => setLook(i)} style={chip(look === i)}>
{l.name}
</button>
))}
</div>
) : null}
<div
ref={root}
style={{
position: "relative",
width: "100%",
flex: "1 1 0",
minHeight: 0,
overflow: "hidden",
perspective: 1400,
perspectiveOrigin: "50% 30%",
WebkitMaskImage: edge,
maskImage: edge,
}}
>
<motion.div
className={`${uid}-plane`}
style={{
position: "absolute",
left: 0,
right: 0,
top: 0,
display: "flex",
flexDirection: "column",
gap,
rotateX: isCanvas ? 0 : rotateX,
scale: isCanvas ? 1 : scale,
y: isCanvas ? 0 : y,
transformOrigin: "50% 0%",
transformStyle: "preserve-3d",
}}
>
{Array.from({ length: rowCount }).map((_, r) => {
const list = buildRow(r)
const width = list.length * (cardWidth + gap)
const duration = speed > 0 ? width / speed : 0
const name = r % 2 === 0 ? "a" : "b"
return (
<div key={r} className={`${uid}-row`} style={{ animation: duration && !isCanvas ? `${uid}-${name} ${duration}s linear infinite` : undefined }}>
{[0, 1].map((copy) =>
list.map((slot, i) => (
<Card
key={`${copy}-${i}`}
item={slot.item}
tone={EMPTY[slot.index % EMPTY.length]}
linkClass={`${uid}-link`}
width={cardWidth}
height={cardHeight}
gap={gap}
radius={radius}
showLabels={showLabels}
labelSize={labelSize}
hidden={r > 0 || copy === 1 || i >= source.length}
recipe={recipe}
/>
))
)}
</div>
)
})}
</motion.div>
</div>
</div>
)
}
function Card({
item,
tone,
linkClass,
width,
height,
gap,
radius,
showLabels,
labelSize,
hidden,
recipe,
}: {
item: Item
tone: string
linkClass: string
width: number
height: number
gap: number
radius: number
showLabels: boolean
labelSize: number
hidden: boolean
recipe: Recipe
}) {
const cover: CSSProperties = { position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block", filter: recipe.filter || "none", transition: "filter .7s ease" }
const inner = (
<div
style={{
position: "relative",
width,
height,
borderRadius: radius,
overflow: "hidden",
background: "#1c1c1a",
boxShadow: "0 1px 0 rgba(255,255,255,.08) inset, 0 28px 50px -28px rgba(20,20,19,.45)",
}}
>
{item.image?.src ? (
<img src={item.image.src} srcSet={item.image.srcSet} sizes={`${width}px`} alt={hidden ? "" : item.image.alt || item.title || ""} loading="eager" decoding="async" draggable={false} style={cover} />
) : (
<div style={{ ...cover, background: tone }} />
)}
<div
style={{
position: "absolute",
inset: 0,
background: recipe.tint || "transparent",
mixBlendMode: recipe.blend || "normal",
opacity: recipe.tint ? 1 : 0,
transition: "opacity .7s ease",
pointerEvents: "none",
}}
/>
{showLabels ? (
<div
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
padding: `${Math.round(labelSize * 3)}px ${Math.round(labelSize * 0.9)}px ${Math.round(labelSize * 0.85)}px`,
background: "linear-gradient(180deg, rgba(10,10,10,0) 0%, rgba(10,10,10,.62) 100%)",
color: "#fff",
fontFamily: FONT,
display: "flex",
flexDirection: "column",
gap: 3,
}}
>
<span style={{ fontSize: labelSize, fontWeight: 600, letterSpacing: "-0.02em", lineHeight: 1.15 }}>{item.title}</span>
{item.meta ? <span style={{ fontSize: Math.max(11, labelSize * 0.72), fontWeight: 400, letterSpacing: "-0.005em", opacity: 0.74, lineHeight: 1.2 }}>{item.meta}</span> : null}
</div>
) : null}
</div>
)
const wrap: CSSProperties = { flex: "0 0 auto", marginRight: gap, display: "block", textDecoration: "none" }
return item.link ? (
<a href={item.link} className={linkClass} style={wrap} aria-hidden={hidden ? true : undefined} tabIndex={hidden ? -1 : undefined} aria-label={item.title}>
{inner}
</a>
) : (
<div style={wrap} aria-hidden={hidden ? true : undefined}>
{inner}
</div>
)
}
addPropertyControls(Shelf, {
font: {
type: ControlType.String,
title: "Font",
defaultValue: "Inter",
description: "The font family name used by your text styles.",
},
items: {
type: ControlType.Array,
title: "Covers",
maxCount: 24,
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Image" },
title: { type: ControlType.String, title: "Title", defaultValue: "Product" },
meta: { type: ControlType.String, title: "Meta", defaultValue: "" },
link: { type: ControlType.Link, title: "Link" },
},
},
defaultValue: ITEMS,
description: "One cover per product. Each row repeats them until it is wider than the screen.",
},
rows: {
type: ControlType.Number,
title: "Rows",
min: 1,
max: 3,
step: 1,
defaultValue: 2,
displayStepper: true,
},
cardWidth: {
type: ControlType.Number,
title: "Card size",
min: 100,
max: 420,
step: 2,
unit: "px",
defaultValue: 232,
},
ratio: {
type: ControlType.Number,
title: "Ratio",
min: 0.6,
max: 1.8,
step: 0.05,
defaultValue: 1.25,
description: "Card height divided by its width.",
},
gap: {
type: ControlType.Number,
title: "Gap",
min: 0,
max: 60,
step: 1,
unit: "px",
defaultValue: 18,
},
radius: {
type: ControlType.Number,
title: "Radius",
min: 0,
max: 40,
step: 1,
unit: "px",
defaultValue: 16,
},
speed: {
type: ControlType.Number,
title: "Drift",
min: 0,
max: 120,
step: 1,
unit: "px/s",
defaultValue: 28,
},
tilt: {
type: ControlType.Number,
title: "Tilt",
min: 0,
max: 65,
step: 1,
unit: "°",
defaultValue: 44,
},
lift: {
type: ControlType.Number,
title: "Zoom",
min: 0,
max: 40,
step: 1,
unit: "%",
defaultValue: 14,
description: "Extra scale while tilted.",
},
flattenOnScroll: {
type: ControlType.Boolean,
title: "Flatten",
enabledTitle: "On scroll",
disabledTitle: "Never",
defaultValue: true,
},
showLabels: {
type: ControlType.Boolean,
title: "Labels",
defaultValue: true,
},
labelSize: {
type: ControlType.Number,
title: "Label size",
min: 11,
max: 28,
step: 1,
unit: "px",
defaultValue: 17,
hidden: (p: Partial<ShelfProps>) => !p.showLabels,
},
fadeEdges: {
type: ControlType.Boolean,
title: "Fade edges",
defaultValue: true,
},
pauseOnHover: {
type: ControlType.Boolean,
title: "On hover",
enabledTitle: "Pause",
disabledTitle: "Keep going",
defaultValue: true,
},
switcher: {
type: ControlType.Boolean,
title: "Look switcher",
defaultValue: true,
description: "Tabs that regrade every cover on the shelf.",
},
prompt: {
type: ControlType.String,
title: "Prompt",
defaultValue: "Preview a look",
hidden: (p: Partial<ShelfProps>) => !p.switcher,
},
originalLabel: {
type: ControlType.String,
title: "First tab",
defaultValue: "Original",
hidden: (p: Partial<ShelfProps>) => !p.switcher,
},
looks: {
type: ControlType.Array,
title: "Looks",
maxCount: 6,
defaultValue: DEFAULT_LOOKS,
hidden: (p: Partial<ShelfProps>) => !p.switcher,
control: {
type: ControlType.Object,
controls: {
name: { type: ControlType.String, title: "Name", defaultValue: "Look" },
style: {
type: ControlType.Enum,
title: "Grade",
options: ["Warm film", "Night teal", "Mono", "Fade"],
optionTitles: ["Warm film", "Night teal", "Mono", "Fade"],
defaultValue: "Warm film",
},
},
},
},
chipScale: {
type: ControlType.Number,
title: "Tab scale",
min: 0.6,
max: 1.4,
step: 0.05,
defaultValue: 1,
hidden: (p: Partial<ShelfProps>) => !p.switcher,
},
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#F2F1EC" },
onInk: { type: ControlType.Color, title: "On ink", defaultValue: "#141413" },
line: { type: ControlType.Color, title: "Line", defaultValue: "rgba(242,241,236,.22)" },
muted: { type: ControlType.Color, title: "Muted", defaultValue: "#8E8C85" },
})
In this preview
The photos are demo pictures shown on this site only; they are not in the files you copy. Set your own through the image props.