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
#0E0E0D
#6E6E6B
#A3A3A0
#FF4A1C
#F5F4F1
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.