Layr Components

Components / Hero

Close Reading

A pinned hero for a few works: the whole picture with a frame travelling over it, a large panel showing the framed detail, one work after another.

Scroll insideFull screen
Props
40%
90 vh
#0E0E0D
#6E6E6B
#A3A3A0
#FF4A1C
#F5F4F1
0 px
48 px

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

Scroll inside the preview.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Close Reading shows up in the Insert menu, and its controls appear in the right panel.
// Close Reading · Layr Components
//
// A pinned hero for a few works, each read up close: the whole picture with a
// small frame that travels over it, and a large panel showing what is inside
// the frame. Scrolling moves the frame down the picture, then the next work
// fades in and is read the same way.
// Paste into a new code file in Framer. Place it at the top of the page at full
// width; it is taller than the window and pins itself while the works are read,
// so no ancestor may clip overflow. A work without a picture shows a plain tone.

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

type Picture = { src: string; srcSet?: string; alt?: string }
type Work = { image?: Picture; title?: string; year?: string; medium?: string; workHeight?: number; workWidth?: number; status?: string; link?: string }

interface CloseReadingProps {
    items: Work[]
    unit: string
    linkLabel: string
    headline: string
    quiet: string
    headingLevel: string
    detailShare: number
    scrollPer: number
    fontFamily: string
    ink: string
    mute: string
    quietTone: string
    accent: string
    ground: string
    topSpace: number
    sideSpace: number
    imageSizes: string
    sharpDetail: string
    detailSizes: string
    style?: CSSProperties
}

const WORKS: Work[] = [
    { title: "Low water", year: "2026", medium: "Oil on linen", workHeight: 150, workWidth: 120, status: "Available" },
    { title: "Evening field", year: "2025", medium: "Oil on board", workHeight: 100, workWidth: 160, status: "Sold" },
    { title: "Dry season", year: "2025", medium: "Oil on linen", workHeight: 150, workWidth: 120, status: "Available" },
]

// Shown when a work has no picture yet.
const EMPTY = ["linear-gradient(165deg, #cfc8bb 0%, #9d9586 55%, #6f685d 100%)", "linear-gradient(165deg, #c4c9c6 0%, #8e9894 55%, #5f6a67 100%)", "linear-gradient(165deg, #d5c7bb 0%, #a39082 55%, #75655a 100%)"]

// The section is as tall as the window plus a stretch of scrolling for each work.
// Every work is drawn as its own layer in the pinned frame; they take turns.
const CSS = `
.lyr-cr{position:relative;width:100%;height:calc(100vh + var(--cr-len));height:calc(100svh + var(--cr-len))}
.lyr-cr-pin{position:sticky;top:0;height:100vh;height:100svh;overflow:hidden}
.lyr-cr-static.lyr-cr{height:800px}
.lyr-cr-static .lyr-cr-pin{position:relative;height:100%}
.lyr-cr-layer{position:absolute;inset:0;container-type:size;isolation:isolate;color:var(--cr-ink);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
.lyr-cr-layer *{box-sizing:border-box}
.lyr-cr-box{position:absolute;inset:0;--cr-left:clamp(350px,37cqw,560px);--cr-gap:clamp(24px,2.8cqw,40px);--cr-wh:clamp(150px,46cqh,440px)}
.lyr-cr-left{position:absolute;left:var(--cr-side);top:0;bottom:0;width:var(--cr-left);display:flex;flex-direction:column;padding-top:calc(var(--cr-top) + clamp(14px,5cqh,44px));padding-bottom:clamp(18px,4.4cqh,40px)}
.lyr-cr-h{margin:0;font-weight:400;font-size:clamp(28px,min(3.3cqw,6.4cqh),56px);line-height:1.04;letter-spacing:-0.035em;text-wrap:balance}
.lyr-cr-h span{color:var(--cr-quiet)}
.lyr-cr-gap{flex:1 1 14px;min-height:14px}
.lyr-cr-row{flex:100 1 0px;min-height:96px;max-height:var(--cr-wh);display:flex;gap:clamp(16px,2.2cqw,32px);align-items:stretch}
.lyr-cr-whole{position:relative;display:block;height:100%;aspect-ratio:var(--cr-r);flex:0 1 auto;max-width:68%;min-width:0;text-decoration:none;color:inherit}
.lyr-cr-wimg{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:left bottom;display:block}
.lyr-cr-draw{position:absolute;left:0;bottom:0;width:100%;height:100%;pointer-events:none}
.lyr-cr-frame{position:absolute;left:0;top:0;width:calc(var(--cr-f) * 100%);height:calc(var(--cr-fh) * 100%);border:1.5px solid var(--cr-accent);box-shadow:0 0 0 1px rgba(255,255,255,.5);will-change:transform}
.lyr-cr-cap{display:flex;flex-direction:column;min-width:0;flex:1 1 0px;font-size:clamp(13px,1.05cqw,15px);line-height:1.5;letter-spacing:-0.01em;color:var(--cr-mute)}
.lyr-cr-t{color:var(--cr-ink);font-size:clamp(16px,1.35cqw,20px);letter-spacing:-0.02em;line-height:1.25;margin-bottom:6px}
.lyr-cr-meta span{display:block;white-space:nowrap}
.lyr-cr-grow{flex:1 1 0px;min-height:6px}
.lyr-cr-status{color:var(--cr-ink);display:flex;align-items:center;gap:8px}
.lyr-cr-dot{width:7px;height:7px;border-radius:50%;flex:none}
.lyr-cr-dot.sold{background:var(--cr-accent)}
.lyr-cr-dot.open{box-shadow:inset 0 0 0 1px var(--cr-ink)}
.lyr-cr-foot{display:flex;justify-content:space-between;align-items:baseline;gap:16px;margin-top:clamp(12px,3cqh,28px);font-size:clamp(13px,1.05cqw,15px);letter-spacing:-0.01em}
.lyr-cr-count{color:var(--cr-mute);font-variant-numeric:tabular-nums}
.lyr-cr-link{color:var(--cr-ink);text-decoration:none;display:inline-flex;align-items:center;gap:8px;border-bottom:1px solid currentColor;padding-bottom:3px;transition:opacity .2s}
.lyr-cr-link:hover{opacity:.62}
.lyr-cr-link svg{width:14px;height:10px;display:block}
.lyr-cr-panel{position:absolute;left:calc(var(--cr-side) + var(--cr-left) + var(--cr-gap));top:var(--cr-top);right:0;bottom:0;overflow:hidden;background:var(--cr-ground)}
.lyr-cr-dimg{position:absolute;left:0;top:0;width:calc(100% / var(--cr-f));max-width:none;height:auto;aspect-ratio:var(--cr-r);display:block;transform-origin:0 0;will-change:transform;user-select:none;-webkit-user-drag:none}
.lyr-cr-sharp{opacity:0;transition:opacity .5s}
.lyr-cr-sharp.in{opacity:1}
@container (max-width:899px){
.lyr-cr-box{display:flex;flex-direction:column;padding-top:calc(var(--cr-top) + clamp(8px,2.2cqh,22px));padding-bottom:max(14px,env(safe-area-inset-bottom))}
.lyr-cr-left{display:contents}
.lyr-cr-h{order:1;padding:0 var(--cr-side);font-size:clamp(27px,min(7.6cqw,5.6cqh),52px);margin-bottom:clamp(12px,2.4cqh,24px)}
.lyr-cr-gap{display:none}
.lyr-cr-panel{order:2;position:relative;left:auto;top:auto;right:auto;bottom:auto;flex:1 1 0px;min-height:120px}
.lyr-cr-row{order:3;flex:0 0 auto;height:clamp(76px,15cqh,168px);max-height:none;min-height:0;padding:0 var(--cr-side);margin-top:clamp(10px,2cqh,18px);gap:14px}
.lyr-cr-whole{max-width:42%}
.lyr-cr-cap{font-size:13.5px;line-height:1.42}
.lyr-cr-t{font-size:17px;margin-bottom:3px}
.lyr-cr-meta span{display:inline;white-space:nowrap}
.lyr-cr-meta span + span::before{content:"\\00a0· ";white-space:normal}
.lyr-cr-foot{order:4;padding:0 var(--cr-side);margin-top:10px;font-size:13.5px}
}
@media (prefers-reduced-motion:reduce){.lyr-cr-sharp{transition:none}}
`

const clamp01 = (v: number) => Math.min(1, Math.max(0, v))
const smooth = (v: number) => {
    const t = clamp01(v)
    return t * t * (3 - 2 * t)
}

// Where the scroll position puts one work: how visible it is and how far its
// frame has travelled. Works take turns; each cross-fades into the next.
function stateAt(progress: number, place: number, total: number) {
    const n = Math.max(1, Math.round(total) || 1)
    const index = Math.min(n, Math.max(1, Math.round(place) || 1)) - 1
    const s = clamp01(progress) * n
    const active = Math.min(n - 1, Math.floor(s))
    const local = s - active
    const fadeFrom = 0.86
    const turn = (local - fadeFrom) / (1 - fadeFrom)
    let opacity = 0
    let words = 0
    if (index === active) {
        opacity = active < n - 1 ? 1 - smooth(turn) : 1
        words = active < n - 1 ? 1 - smooth(turn * 2) : 1
    } else if (index === active + 1) {
        opacity = smooth(turn)
        words = smooth(turn * 2 - 1)
    }
    let travel = 0
    if (index < active) travel = 1
    else if (index === active) travel = clamp01(local / (active < n - 1 ? fadeFrom : 0.94))
    return { opacity, words, travel, near: index <= active + 1, stops: n >= 3 ? 2 : n === 2 ? 3 : 4, flip: index % 2 === 1 }
}

// The frame zigzags down the painting from stop to stop.
function pathAt(travel: number, stops: number, flip: boolean) {
    const legs = Math.max(1, stops - 1)
    const k = Math.min(legs - 1, Math.floor(travel * legs))
    const e = smooth(travel * legs - k)
    const stop = (i: number) => ({ u: (i % 2 === 0) !== flip ? 0.08 : 0.92, v: 0.05 + 0.9 * (i / legs) })
    const a = stop(k)
    const b = stop(k + 1)
    return { u: a.u + (b.u - a.u) * e, v: a.v + (b.v - a.v) * e }
}

// The section's scroll position, eased so a mouse wheel's steps come out as one
// smooth movement. Each work listens to it.
type Track = { goal: number; shown: number; raf: number; last: number; subs: Set<() => void> }

interface WorkProps {
    work: Work
    index: number
    total: number
    isStatic: boolean
    register: (fn: () => void) => () => void
    read: () => number
    heading: ReactNode
    headingTag: "h1" | "h2"
    unit: string
    linkLabel: string
    detailShare: number
    imageSizes: string
    sharpDetail: string
    detailSizes: string
}

function CloseReadingWork({ work, index, total, isStatic, register, read, heading, headingTag, unit, linkLabel, detailShare, imageSizes, sharpDetail, detailSizes }: WorkProps) {
    const place = index + 1
    const first = index === 0
    const title = String(work.title ?? "")
    const year = String(work.year ?? "")
    const medium = String(work.medium ?? "")
    const status = String(work.status ?? "")
    const link = work.link
    const workHeight = Number(work.workHeight) || 0
    const workWidth = Number(work.workWidth) || 0
    const sharpMode = String(sharpDetail).toLowerCase()
    const wantSharp = sharpMode.includes("every") || (sharpMode.includes("first") && first)
    const src = work.image?.src || ""
    const srcSet = work.image?.srcSet || undefined
    const alt = work.image?.alt || title

    const measured = workHeight > 0 && workWidth > 0
    const [ratio, setRatio] = useState(measured ? workWidth / workHeight : 0.75)
    const [panelAspect, setPanelAspect] = useState(1)
    const [near, setNear] = useState(first)
    const [sharp, setSharp] = useState(false)
    const [sharpIn, setSharpIn] = useState(false)
    const [idle, setIdle] = useState(false)

    const rootRef = useRef<HTMLDivElement>(null)
    const capRef = useRef<HTMLDivElement>(null)
    const footRef = useRef<HTMLDivElement>(null)
    const panelRef = useRef<HTMLDivElement>(null)
    const wholeRef = useRef<HTMLAnchorElement>(null)
    const drawRef = useRef<HTMLDivElement>(null)
    const frameRef = useRef<HTMLDivElement>(null)
    const softRef = useRef<HTMLElement | null>(null)
    const sharpRef = useRef<HTMLImageElement>(null)

    // Share of the painting's width that the panel shows, kept small enough
    // that the frame is never taller than the painting.
    const share = Math.min(0.9, Math.max(0.12, detailShare / 100))
    const f = Math.min(share, 0.92 / Math.max(0.01, panelAspect * ratio))
    const fh = Math.min(1, f * panelAspect * ratio)

    const initial = stateAt(0, place, total)
    const start = pathAt(initial.travel, initial.stops, initial.flip)
    const startX = start.u * (1 - f)
    const startY = start.v * (1 - fh)
    const showPictures = near && !!src
    const showSharp = showPictures && wantSharp && sharp && idle && !isStatic

    // Follow the section's scroll track; repaint when the geometry changes.
    useEffect(() => {
        if (isStatic) return
        const apply = () => {
            const st = stateAt(read(), place, total)
            const at = pathAt(st.travel, st.stops, st.flip)
            const x = at.u * (1 - f)
            const y = at.v * (1 - fh)
            const layers: [HTMLElement | null, number][] = [
                [wholeRef.current, st.opacity],
                [panelRef.current, st.opacity],
                [capRef.current, st.words],
                [footRef.current, st.words],
            ]
            for (const [el, value] of layers) {
                if (!el) continue
                el.style.opacity = String(value)
                el.style.visibility = value > 0.004 ? "visible" : "hidden"
            }
            if (rootRef.current) {
                rootRef.current.style.pointerEvents = st.opacity > 0.5 ? "auto" : "none"
                rootRef.current.style.zIndex = st.opacity > 0.5 ? "2" : "1"
            }
            const move = `translate3d(${(-x * 100).toFixed(3)}%, ${(-y * 100).toFixed(3)}%, 0)`
            if (softRef.current) softRef.current.style.transform = move
            if (sharpRef.current) sharpRef.current.style.transform = move
            if (frameRef.current) frameRef.current.style.transform = `translate3d(${((x / f) * 100).toFixed(3)}%, ${((y / fh) * 100).toFixed(3)}%, 0)`
            if (st.near) setNear(true)
            if (st.opacity > 0.004) setSharp(true)
        }
        return register(apply)
    }, [isStatic, register, read, place, total, f, fh, near, showSharp])

    // The panel's shape decides the frame's shape; the drawn picture decides
    // where the frame may travel.
    useEffect(() => {
        if (isStatic || typeof ResizeObserver === "undefined") return
        const panel = panelRef.current
        const whole = wholeRef.current
        if (!panel || !whole) return
        const ro = new ResizeObserver(() => {
            const pw = panel.clientWidth
            const ph = panel.clientHeight
            if (pw > 0 && ph > 0) setPanelAspect(Math.round((ph / pw) * 1000) / 1000)
            const bw = whole.clientWidth
            const bh = whole.clientHeight
            const draw = drawRef.current
            if (draw && bw > 0 && bh > 0) {
                const w = Math.min(bw, bh * ratio)
                draw.style.width = w + "px"
                draw.style.height = w / ratio + "px"
            }
        })
        ro.observe(panel)
        ro.observe(whole)
        return () => ro.disconnect()
    }, [isStatic, ratio])

    // The sharp picture is large, so it waits until the page has painted.
    useEffect(() => {
        if (isStatic) return
        const timer = window.setTimeout(() => setIdle(true), first ? 700 : 1500)
        return () => window.clearTimeout(timer)
    }, [isStatic, first])

    const onPicture = (img: HTMLImageElement | null) => {
        if (!img || !img.naturalWidth || !img.naturalHeight) return
        const natural = img.naturalWidth / img.naturalHeight
        if (Math.abs(natural - ratio) / natural > 0.012) setRatio(natural)
    }

    const state = status.toLowerCase()
    const dot = /sold|reserved/.test(state) ? "sold" : /available|for sale/.test(state) ? "open" : ""
    const size = measured ? `${workHeight} × ${workWidth} ${unit}`.trim() : ""
    const visible = initial.opacity > 0.004
    const fade: CSSProperties = { opacity: initial.opacity, visibility: visible ? "visible" : "hidden" }
    const move = `translate3d(${(-startX * 100).toFixed(3)}%, ${(-startY * 100).toFixed(3)}%, 0)`
    const empty = EMPTY[index % EMPTY.length]
    const Heading = headingTag

    return (
        <div ref={rootRef} className="lyr-cr-layer" style={{ pointerEvents: visible ? "auto" : "none", zIndex: visible ? 2 : 1, "--cr-r": String(ratio), "--cr-f": String(f), "--cr-fh": String(fh) } as CSSProperties}>
            <div className="lyr-cr-box">
                <div className="lyr-cr-left">
                    {first ? (
                        <Heading className="lyr-cr-h">{heading}</Heading>
                    ) : (
                        <div className="lyr-cr-h" aria-hidden="true" style={{ visibility: "hidden" }}>
                            {heading}
                        </div>
                    )}
                    <div className="lyr-cr-gap" />
                    <div className="lyr-cr-row">
                        <a ref={wholeRef} className="lyr-cr-whole" href={link || undefined} aria-label={title} style={fade}>
                            {showPictures && (
                                <img
                                    className="lyr-cr-wimg"
                                    srcSet={srcSet}
                                    sizes={imageSizes}
                                    src={src}
                                    alt={alt}
                                    loading={first ? "eager" : "lazy"}
                                    decoding="async"
                                    ref={(img) => {
                                        if (img && img.complete) onPicture(img)
                                    }}
                                    onLoad={(e) => onPicture(e.currentTarget)}
                                />
                            )}
                            <div ref={drawRef} className="lyr-cr-draw" style={src ? undefined : { background: empty }}>
                                <div ref={frameRef} className="lyr-cr-frame" style={{ transform: `translate3d(${((startX / f) * 100).toFixed(3)}%, ${((startY / fh) * 100).toFixed(3)}%, 0)` }} />
                            </div>
                        </a>
                        <div ref={capRef} className="lyr-cr-cap" style={fade}>
                            <span className="lyr-cr-t">{title}</span>
                            <div className="lyr-cr-meta">
                                {year && <span>{year}</span>}
                                {medium && <span>{medium}</span>}
                                {size && <span>{size}</span>}
                            </div>
                            <span className="lyr-cr-grow" />
                            {status && (
                                <span className="lyr-cr-status">
                                    {dot && <span className={`lyr-cr-dot ${dot}`} />}
                                    {status}
                                </span>
                            )}
                        </div>
                    </div>
                    <div ref={footRef} className="lyr-cr-foot" style={fade}>
                        <span className="lyr-cr-count">
                            {place} of {total}
                        </span>
                        {link && (
                            <a className="lyr-cr-link" href={link}>
                                {linkLabel}
                                <svg viewBox="0 0 14 10" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
                                    <path d="M0 5h13M9 1l4 4-4 4" />
                                </svg>
                            </a>
                        )}
                    </div>
                </div>
                <div ref={panelRef} className="lyr-cr-panel" style={fade} aria-hidden="true">
                    {!src && (
                        <div
                            ref={(el) => {
                                softRef.current = el
                            }}
                            className="lyr-cr-dimg"
                            style={{ transform: move, background: empty }}
                        />
                    )}
                    {showPictures && (
                        <img
                            ref={(el) => {
                                softRef.current = el
                            }}
                            className="lyr-cr-dimg"
                            srcSet={srcSet}
                            sizes={imageSizes}
                            src={src}
                            alt=""
                            loading={first ? "eager" : "lazy"}
                            decoding="async"
                            style={{ transform: move }}
                        />
                    )}
                    {showSharp && (
                        <img
                            ref={sharpRef}
                            className={sharpIn ? "lyr-cr-dimg lyr-cr-sharp in" : "lyr-cr-dimg lyr-cr-sharp"}
                            srcSet={srcSet}
                            sizes={detailSizes}
                            src={src}
                            alt=""
                            decoding="async"
                            style={{ transform: move }}
                            onLoad={() => setSharpIn(true)}
                        />
                    )}
                </div>
            </div>
        </div>
    )
}

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 800
 *
 * @framerSupportedLayoutWidth fixed
 * @framerSupportedLayoutHeight auto
 */
export default function CloseReading(props: CloseReadingProps) {
    const {
        items = WORKS,
        unit = "cm",
        linkLabel = "View the work",
        headline = "Recent paintings,",
        quiet = "made to be read up close.",
        headingLevel = "H1",
        detailShare = 40,
        scrollPer = 90,
        fontFamily = "Inter",
        ink = "#14130F",
        mute = "#77746B",
        quietTone = "#A3A097",
        accent = "#E2502A",
        ground = "#EFEDE7",
        topSpace = 72,
        sideSpace = 48,
        imageSizes = "60vw",
        sharpDetail = "First work",
        detailSizes = "150vw",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const list = Array.isArray(items) ? items : []
    const n = Math.max(1, list.length)
    const rootRef = useRef<HTMLDivElement>(null)
    const trackRef = useRef<Track | null>(null)

    const track = useCallback((): Track => {
        if (!trackRef.current) trackRef.current = { goal: 0, shown: 0, raf: 0, last: 0, subs: new Set() }
        return trackRef.current
    }, [])
    const register = useCallback(
        (fn: () => void) => {
            const t = track()
            t.subs.add(fn)
            fn()
            return () => {
                t.subs.delete(fn)
            }
        },
        [track]
    )
    const read = useCallback(() => track().shown, [track])

    // How far the section has been scrolled: 0 at its top, 1 when its bottom
    // meets the bottom of the window.
    useEffect(() => {
        const root = rootRef.current
        if (!root || isStatic) return
        const t = track()
        const reduce = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches
        const notify = () => t.subs.forEach((fn) => fn())
        const tick = (now: number) => {
            const dt = Math.min(64, now - t.last)
            t.last = now
            const gap = t.goal - t.shown
            if (Math.abs(gap) < 0.0004) {
                t.shown = t.goal
                t.raf = 0
            } else {
                t.shown += gap * (1 - Math.exp(-dt / 110))
                t.raf = requestAnimationFrame(tick)
            }
            notify()
        }
        const set = (value: number) => {
            t.goal = value
            if (reduce) {
                t.shown = value
                notify()
                return
            }
            if (!t.raf) {
                t.last = performance.now()
                t.raf = requestAnimationFrame(tick)
            }
        }
        const measure = () => {
            const rect = root.getBoundingClientRect()
            const range = rect.height - window.innerHeight
            set(range > 1 ? clamp01(-rect.top / range) : 0)
        }
        measure()
        t.shown = t.goal
        notify()
        window.addEventListener("scroll", measure, { passive: true })
        window.addEventListener("resize", measure)
        return () => {
            window.removeEventListener("scroll", measure)
            window.removeEventListener("resize", measure)
            if (t.raf) cancelAnimationFrame(t.raf)
            t.raf = 0
        }
    }, [isStatic, track])

    const rootStyle = {
        ...style,
        fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif`,
        "--cr-len": `${n * Math.max(20, Number(scrollPer) || 90)}vh`,
        "--cr-ink": ink,
        "--cr-mute": mute,
        "--cr-quiet": quietTone,
        "--cr-accent": accent,
        "--cr-ground": ground,
        "--cr-top": `${topSpace}px`,
        "--cr-side": `min(${sideSpace}px, 5cqw)`,
    } as CSSProperties
    const heading = (
        <>
            {headline} <span>{quiet}</span>
        </>
    )
    const headingTag = String(headingLevel).toLowerCase().includes("2") ? "h2" : "h1"

    return (
        <div ref={rootRef} className={isStatic ? "lyr-cr lyr-cr-static" : "lyr-cr"} style={rootStyle}>
            <style dangerouslySetInnerHTML={{ __html: CSS }} />
            <div className="lyr-cr-pin">
                {list.map((work, i) => (
                    <CloseReadingWork
                        key={i}
                        work={work || {}}
                        index={i}
                        total={n}
                        isStatic={isStatic}
                        register={register}
                        read={read}
                        heading={heading}
                        headingTag={headingTag}
                        unit={unit}
                        linkLabel={linkLabel}
                        detailShare={detailShare}
                        imageSizes={imageSizes}
                        sharpDetail={sharpDetail}
                        detailSizes={detailSizes}
                    />
                ))}
            </div>
        </div>
    )
}

addPropertyControls(CloseReading, {
    items: {
        type: ControlType.Array,
        title: "Works",
        maxCount: 12,
        control: {
            type: ControlType.Object,
            controls: {
                image: { type: ControlType.ResponsiveImage, title: "Image", description: "The photograph of the work." },
                title: { type: ControlType.String, title: "Title", defaultValue: "" },
                year: { type: ControlType.String, title: "Year", defaultValue: "" },
                medium: { type: ControlType.String, title: "Medium", defaultValue: "" },
                workHeight: { type: ControlType.Number, title: "Height of work", defaultValue: 0, min: 0, step: 0.1, displayStepper: true, description: "The real height. With the width it gives the size line and the picture's shape." },
                workWidth: { type: ControlType.Number, title: "Width of work", defaultValue: 0, min: 0, step: 0.1, displayStepper: true },
                status: { type: ControlType.String, title: "Status", defaultValue: "", description: "Sold shows a filled dot, Available a ring, anything else no dot." },
                link: { type: ControlType.Link, title: "Link" },
            },
        },
        defaultValue: WORKS,
        description: "One per work, in the order they are read.",
    },
    unit: { type: ControlType.String, title: "Unit", defaultValue: "cm" },
    linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "View the work" },
    headline: { type: ControlType.String, title: "Headline", defaultValue: "Recent paintings,", displayTextArea: true },
    quiet: { type: ControlType.String, title: "Headline, quiet part", defaultValue: "made to be read up close.", displayTextArea: true },
    headingLevel: { type: ControlType.Enum, title: "Headline tag", options: ["H1", "H2"], optionTitles: ["H1", "H2"], defaultValue: "H1", description: "Use H1 once per page, at the top. Further down, use H2." },
    detailShare: { type: ControlType.Number, title: "Detail share", defaultValue: 40, min: 15, max: 80, step: 1, unit: "%", description: "How much of the painting's width the large panel shows. Smaller is closer." },
    scrollPer: { type: ControlType.Number, title: "Scroll per work", defaultValue: 90, min: 40, max: 200, step: 5, unit: "vh", description: "How much scrolling it takes to read one work." },
    fontFamily: { type: ControlType.String, title: "Font", defaultValue: "Inter", description: "The family name used by the site's text styles." },
    ink: { type: ControlType.Color, title: "Ink", defaultValue: "#14130F" },
    mute: { type: ControlType.Color, title: "Muted", defaultValue: "#77746B" },
    quietTone: { type: ControlType.Color, title: "Quiet tone", defaultValue: "#A3A097", description: "The second tone of the headline." },
    accent: { type: ControlType.Color, title: "Accent", defaultValue: "#E2502A" },
    ground: { type: ControlType.Color, title: "Ground", defaultValue: "#EFEDE7", description: "Shown behind the detail while the picture loads." },
    topSpace: { type: ControlType.Number, title: "Top space", defaultValue: 72, min: 0, max: 200, step: 1, unit: "px", description: "Height of the navigation above." },
    sideSpace: { type: ControlType.Number, title: "Side space", defaultValue: 48, min: 0, max: 120, step: 1, unit: "px", description: "Shrinks to 5% of the width on narrow screens." },
    imageSizes: { type: ControlType.String, title: "Image width", defaultValue: "60vw", description: "One length, for example 60vw. Tells the browser which file size to load." },
    sharpDetail: {
        type: ControlType.Enum,
        title: "Sharp detail",
        options: ["Off", "First work", "Every work"],
        optionTitles: ["Off", "First work", "Every work"],
        defaultValue: "First work",
        description: "Loads a second, larger file for the detail once the page has painted. Crisper on high-density screens, and about 2 MB for each work it applies to.",
    },
    detailSizes: {
        type: ControlType.String,
        title: "Detail width",
        defaultValue: "150vw",
        description: "One length for the sharp detail file.",
        hidden: (props: Partial<CloseReadingProps>) => String(props.sharpDetail || "").toLowerCase() === "off",
    },
})

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.