Layr Components

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
2
232 px
1.25
18 px
16 px
28 px/s
44 °
14%
17 px
1
#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.