Components / Hero
Turntable
A ring of round pictures that turns like a lazy Susan on drag, arrows, keys or scroll; the front one's title and note sit beside it.
Props
#FFFFFF
#F5F4F1
#E6E5E1
#0E0E0D
#6E6E6B
#0E0E0D
#FFFFFF
Set in code: Items. The preview fills these in with demo content.
Drag the table, press the arrows, or use the arrow keys.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Turntable shows up in the Insert menu, and its controls appear in the right panel.
// Turntable · Layr Components
//
// A ring of round pictures on a table that turns like a lazy Susan: drag it
// round (with a throw), use the arrows or the arrow keys, or scroll the page.
// It also turns by itself now and then. The picture at the front grows, and its
// title, a short note and a price or other detail sit beside it.
// Paste into a new code file in Framer and give it a fixed width and height;
// the wheel is placed in percent of that box, so set the geometry per
// breakpoint. Add the pictures in the Items list. Without a picture an item
// shows a plain disc.
import { addPropertyControls, ControlType, useIsStaticRenderer } from "framer"
import { animate, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useScroll } from "framer-motion"
import { startTransition, useCallback, useEffect, useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"
interface PlateImage {
src: string
srcSet?: string
alt?: string
}
interface TurntableItem {
image?: PlateImage
title: string
note?: string
price?: string
}
interface TurntableProps {
items: TurntableItem[]
centerX: number
centerY: number
radiusByWidth: number
radiusByHeight: number
frontAngle: number
frontSize: number
plateSize: number
labelX: number
labelY: number
labelAlign: string
ringText: string
showOrbit: boolean
cursorLabel: string
showArrows: boolean
currency: string
autoplay: number
scrollTurn: number
rim: number
rimColor: string
discColor: string
lineColor: string
textColor: string
mutedColor: string
buttonColor: string
buttonTextColor: string
fontFamily: string
displayFamily: string
titleSize: number
titleWeight: number
style?: CSSProperties
}
const ITEMS: TurntableItem[] = [
{ title: "Drag the table to turn it", note: "Start here" },
{ title: "Or use the arrows and keys", note: "Next" },
{ title: "It turns as the page scrolls", note: "Scroll" },
{ title: "And by itself, now and then", note: "Wait" },
{ title: "Press a plate to bring it round", note: "Press" },
{ title: "Every picture gets its turn", note: "Last" },
]
// Shown for an item without a picture: quiet tones, so the ring still reads.
const EMPTY = [
"radial-gradient(circle at 34% 30%, #EFE4CF, #CDBB9A)",
"radial-gradient(circle at 34% 30%, #E4E7D6, #B9C1A2)",
"radial-gradient(circle at 34% 30%, #EFDCCF, #CFA48E)",
"radial-gradient(circle at 34% 30%, #DFE3E8, #AEB7C2)",
"radial-gradient(circle at 34% 30%, #F1E7D2, #D3C29E)",
"radial-gradient(circle at 34% 30%, #DDE2D5, #A9B39C)",
]
const SHEET = `
.lyr-tt .lyr-tt-plate { position: absolute; left: 0; top: 0; border-radius: 50%; will-change: transform; cursor: var(--lyr-tt-cursor); transform-origin: 50% 50%; }
.lyr-tt .lyr-tt-plate:active { cursor: var(--lyr-tt-cursor-active); }
.lyr-tt .lyr-tt-cursor { position: absolute; left: 0; top: 0; z-index: 9; width: 74px; height: 74px; border-radius: 50%; background: var(--lyr-tt-btn); color: var(--lyr-tt-btn-text); display: grid; place-items: center; font-size: 14px; font-weight: 500; pointer-events: none; opacity: 0; transform: translate3d(-200px,-200px,0); transition: opacity .25s ease, transform .18s cubic-bezier(.2,.8,.2,1); will-change: transform; }
@media (hover: none) { .lyr-tt .lyr-tt-cursor { display: none; } }
.lyr-tt .lyr-tt-land { width: 100%; height: 100%; border-radius: 50%; box-shadow: 0 26px 44px -22px rgba(40,25,5,0.38), 0 3px 10px rgba(40,25,5,0.10); }
.lyr-tt .lyr-tt-spin { width: 100%; height: 100%; border-radius: 50%; overflow: hidden; box-sizing: border-box; }
.lyr-tt [data-landed="false"] .lyr-tt-land { opacity: 0; transform: translateY(-46px) scale(1.22); }
.lyr-tt [data-landed="true"] .lyr-tt-land { opacity: 1; transform: none; transition: opacity .5s ease, transform .9s cubic-bezier(.2,.9,.25,1.12); }
.lyr-tt .lyr-tt-spin img, .lyr-tt .lyr-tt-spin i { width: 100%; height: 100%; display: block; object-fit: cover; border-radius: 50%; pointer-events: none; }
.lyr-tt .lyr-tt-btn { width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--lyr-tt-line); background: transparent; color: var(--lyr-tt-text); display: grid; place-items: center; cursor: pointer; padding: 0; transition: background .2s ease, color .2s ease, transform .2s ease; }
.lyr-tt .lyr-tt-btn:hover { background: var(--lyr-tt-btn); color: var(--lyr-tt-btn-text); border-color: var(--lyr-tt-btn); }
.lyr-tt .lyr-tt-btn:active { transform: scale(.94); }
.lyr-tt .lyr-tt-btn.lyr-tt-fill { background: var(--lyr-tt-btn); color: var(--lyr-tt-btn-text); border-color: var(--lyr-tt-btn); }
.lyr-tt .lyr-tt-swap { animation: lyr-tt-in .45s cubic-bezier(.2,.8,.2,1) both; }
@keyframes lyr-tt-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
.lyr-tt:focus-visible { outline: 2px solid var(--lyr-tt-btn); outline-offset: -2px; }
@media (prefers-reduced-motion: reduce) { .lyr-tt .lyr-tt-swap { animation: none; } .lyr-tt [data-landed="true"] .lyr-tt-land { transition: none; } }
`
const mod = (n: number, m: number) => ((n % m) + m) % m
const pad = (n: number) => String(n).padStart(2, "0")
/**
* @framerIntrinsicWidth 1200
* @framerIntrinsicHeight 800
*
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight fixed
*/
export default function Turntable(props: TurntableProps) {
const {
items = ITEMS,
centerX = 82,
centerY = 89,
radiusByWidth = 36,
radiusByHeight = 56,
frontAngle = -120,
frontSize = 80,
plateSize = 47,
labelX = -34,
labelY = -39,
labelAlign = "left",
ringText = "",
showOrbit = false,
cursorLabel = "Drag",
showArrows = true,
currency = "",
autoplay = 5,
scrollTurn = 1.2,
rim = 0,
rimColor = "#FBF8F2",
discColor = "#E8E0D0",
lineColor = "rgba(27,26,23,0.2)",
textColor = "#1B1A17",
mutedColor = "#8A8375",
buttonColor = "#1B1A17",
buttonTextColor = "#F2EDE3",
fontFamily = "Inter",
displayFamily = "",
titleSize = 28,
titleWeight = 400,
style,
} = props
const isStatic = useIsStaticRenderer()
const reduced = useReducedMotion()
const uid = useId().replace(/[^a-zA-Z0-9]/g, "")
const root = useRef<HTMLDivElement>(null)
const plateRefs = useRef<(HTMLDivElement | null)[]>([])
const spinRefs = useRef<(HTMLDivElement | null)[]>([])
const drag = useRef({ on: false, id: -1, angle: 0, moved: 0, v: 0, t: 0, index: -1 })
const ringRef = useRef<SVGGElement>(null)
const inView = useInView(root, { amount: 0.25 })
const [box, setBox] = useState({ w: 1200, h: 800 })
const [active, setActive] = useState(0)
const [landed, setLanded] = useState(false)
useEffect(() => {
const el = root.current
if (!el || typeof ResizeObserver === "undefined") return
const ro = new ResizeObserver((entries) => {
const r = entries[0].contentRect
if (r.width > 0 && r.height > 0) setBox((prev) => (Math.abs(prev.w - r.width) > 0.5 || Math.abs(prev.h - r.height) > 0.5 ? { w: r.width, h: r.height } : prev))
})
ro.observe(el)
return () => ro.disconnect()
}, [])
const given = (Array.isArray(items) ? items : []).filter((p) => p && (p.title || p.image?.src))
const plates = given.length ? given : ITEMS
const count = plates.length
const step = 360 / count
// Geometry, all derived from the component's own box.
const R = Math.min((box.w * radiusByWidth) / 100, radiusByHeight > 0 ? (box.h * radiusByHeight) / 100 : Infinity)
const cx = (box.w * centerX) / 100
const cy = (box.h * centerY) / 100
const chord = 2 * R * Math.sin((step * Math.PI) / 360)
const small = Math.min((R * plateSize) / 100, chord * 0.86)
const front = Math.max(small, Math.min((R * frontSize) / 100, 2 * (chord - small / 2) * 0.94))
const discR = R * 0.84
const textR = R * 1.22
const pos = useMotionValue(0)
const { scrollY } = useScroll()
const geo = useRef({ cx, cy, R, small, front, step, count, frontAngle, scrollTurn })
geo.current = { cx, cy, R, small, front, step, count, frontAngle, scrollTurn }
const activeRef = useRef(0)
const paint = useCallback(() => {
const g = geo.current
const p = pos.get() + (isStatic ? 0 : (scrollY.get() * g.scrollTurn) / 1000)
for (let i = 0; i < g.count; i++) {
const el = plateRefs.current[i]
if (!el) continue
const d = mod(i - p + g.count / 2, g.count) - g.count / 2 // signed distance from the front, in plates
const deg = g.frontAngle + d * g.step
const rad = (deg * Math.PI) / 180
const near = Math.max(0, 1 - Math.abs(d))
const ease = 0.5 - 0.5 * Math.cos(Math.PI * near)
const size = g.small + (g.front - g.small) * ease
const x = g.cx + g.R * Math.cos(rad) - g.front / 2
const y = g.cy + g.R * Math.sin(rad) - g.front / 2
el.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) scale(${(size / g.front).toFixed(4)})`
el.style.zIndex = String(2 + Math.round(ease * 4))
const spin = spinRefs.current[i]
if (spin) spin.style.transform = `rotate(${(d * g.step).toFixed(2)}deg)`
}
if (ringRef.current) ringRef.current.setAttribute("transform", `rotate(${(-p * g.step * 0.4).toFixed(2)} ${g.cx.toFixed(1)} ${g.cy.toFixed(1)})`)
const idx = mod(Math.round(p), g.count)
if (idx !== activeRef.current) {
activeRef.current = idx
startTransition(() => setActive(idx))
}
}, [isStatic, pos, scrollY])
useMotionValueEvent(pos, "change", paint)
useMotionValueEvent(scrollY, "change", paint)
useEffect(() => {
paint()
})
useEffect(() => {
if (isStatic) return
const t = window.setTimeout(() => setLanded(true), 80)
return () => window.clearTimeout(t)
}, [isStatic])
const lastTouch = useRef(0)
const hover = useRef(false)
const cursor = useRef<HTMLDivElement>(null)
// The Drag cursor trails the pointer over plates and the table (mouse only).
const trackCursor = (e: ReactPointerEvent<HTMLDivElement>) => {
const el = cursor.current
if (!el || e.pointerType !== "mouse") return
const r = root.current!.getBoundingClientRect()
const over = !!(e.target as HTMLElement).closest("[data-plate],[data-disc]") && !(e.target as HTMLElement).closest("button")
el.style.transform = `translate3d(${(e.clientX - r.left).toFixed(1)}px, ${(e.clientY - r.top).toFixed(1)}px, 0) translate(-50%, -50%) scale(${over || drag.current.on ? 1 : 0.4})`
el.style.opacity = over || drag.current.on ? "1" : "0"
}
const settle = useCallback(
(target: number) => {
lastTouch.current = Date.now()
if (reduced) pos.set(target)
else animate(pos, target, { type: "spring", stiffness: 70, damping: 16, mass: 1 })
},
[pos, reduced]
)
const turn = useCallback((by: number) => settle(Math.round(pos.get()) + by), [pos, settle])
const goTo = useCallback(
(i: number) => {
const g = geo.current
const total = pos.get() + (scrollY.get() * g.scrollTurn) / 1000
let diff = mod(i - total, g.count)
if (diff > g.count / 2) diff -= g.count
settle(pos.get() + diff)
},
[pos, scrollY, settle]
)
useEffect(() => {
if (isStatic || reduced || !autoplay || !inView || count < 2) return
const id = window.setInterval(() => {
if (hover.current || drag.current.on) return
if (Date.now() - lastTouch.current < autoplay * 1000) return
animate(pos, Math.round(pos.get()) + 1, { type: "spring", stiffness: 60, damping: 16, mass: 1 })
}, autoplay * 1000)
return () => window.clearInterval(id)
}, [isStatic, reduced, autoplay, inView, count, pos])
// Drag: follow the pointer's angle around the wheel centre, then settle on the nearest plate.
const angleOf = (e: ReactPointerEvent) => {
const r = root.current!.getBoundingClientRect()
return (Math.atan2(e.clientY - r.top - geo.current.cy, e.clientX - r.left - geo.current.cx) * 180) / Math.PI
}
const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement
const hit = target.closest("[data-plate],[data-disc]") as HTMLElement | null
if (!hit || (e.pointerType === "mouse" && e.button !== 0)) return
drag.current = { on: true, id: e.pointerId, angle: angleOf(e), moved: 0, v: 0, t: performance.now(), index: hit.dataset.plate ? Number(hit.dataset.plate) : -1 }
pos.stop()
lastTouch.current = Date.now()
}
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const d = drag.current
if (!d.on || e.pointerId !== d.id) return
const a = angleOf(e)
let delta = a - d.angle
if (delta > 180) delta -= 360
if (delta < -180) delta += 360
d.angle = a
d.moved += Math.abs(delta)
if (d.moved > 1.2 && !root.current!.hasPointerCapture(e.pointerId)) root.current!.setPointerCapture(e.pointerId)
const now = performance.now()
const dt = Math.max(1, now - d.t)
d.v = 0.7 * d.v + 0.3 * ((-delta / geo.current.step) * 1000) / dt
d.t = now
pos.set(pos.get() - delta / geo.current.step)
}
const onUp = (e: ReactPointerEvent<HTMLDivElement>) => {
const d = drag.current
if (!d.on || e.pointerId !== d.id) return
d.on = false
if (d.moved < 1.2) {
if (d.index >= 0) goTo(d.index)
return
}
const stale = performance.now() - d.t > 80
const throwBy = stale ? 0 : Math.max(-2.2, Math.min(2.2, d.v * 0.22))
const g = geo.current
const sp = (scrollY.get() * g.scrollTurn) / 1000
settle(Math.round(pos.get() + sp + throwBy) - sp)
}
const at = Math.min(active, count - 1)
const current = plates[at]
const lx = cx + (R * labelX) / 100
const ly = cy + (R * labelY) / 100
const centered = String(labelAlign).toLowerCase().includes("cent")
const family = `"${fontFamily}", "Inter", system-ui, sans-serif`
const display = displayFamily ? `"${displayFamily}", ${family}` : family
const k = Math.max(0.72, Math.min(1.15, R / 500))
return (
<div
ref={root}
className="lyr-tt"
role="group"
aria-roledescription="carousel"
aria-label="Pictures on a turning table"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "ArrowRight") turn(1)
if (e.key === "ArrowLeft") turn(-1)
}}
onPointerDown={isStatic ? undefined : onDown}
onPointerMove={
isStatic
? undefined
: (e) => {
onMove(e)
trackCursor(e)
}
}
onPointerUp={isStatic ? undefined : onUp}
onPointerCancel={isStatic ? undefined : onUp}
onPointerEnter={() => (hover.current = true)}
onPointerLeave={() => {
hover.current = false
if (cursor.current) cursor.current.style.opacity = "0"
}}
style={{ ...style, position: "relative", width: "100%", height: "100%", fontFamily: family, color: textColor, touchAction: "pan-y", userSelect: "none", WebkitUserSelect: "none", outline: "none", "--lyr-tt-text": textColor, "--lyr-tt-line": lineColor, "--lyr-tt-btn": buttonColor, "--lyr-tt-btn-text": buttonTextColor, "--lyr-tt-cursor": cursorLabel ? "none" : "grab", "--lyr-tt-cursor-active": cursorLabel ? "none" : "grabbing" } as CSSProperties}
>
<style dangerouslySetInnerHTML={{ __html: SHEET }} />
<div
data-disc="1"
style={{
position: "absolute",
left: cx - discR,
top: cy - discR,
width: discR * 2,
height: discR * 2,
borderRadius: "50%",
background: discColor,
cursor: cursorLabel ? "none" : "grab",
}}
/>
<svg aria-hidden="true" width={box.w} height={box.h} style={{ position: "absolute", left: 0, top: 0, overflow: "visible", pointerEvents: "none" }}>
{showOrbit ? <circle cx={cx} cy={cy} r={R} fill="none" stroke={lineColor} strokeDasharray="2 9" /> : null}
<defs>
<path id={`lyr-tt-arc-${uid}`} d={`M ${cx - textR} ${cy} a ${textR} ${textR} 0 1 1 ${textR * 2} 0 a ${textR} ${textR} 0 1 1 ${-textR * 2} 0`} />
</defs>
{ringText ? (
<g ref={ringRef}>
<text fontFamily={family} fontSize={14 * k} fontWeight={500} letterSpacing={1.5} fill={mutedColor}>
<textPath href={`#lyr-tt-arc-${uid}`}>{ringText.repeat(Math.max(1, Math.ceil((2 * Math.PI * textR) / Math.max(120, ringText.length * 8.6 * k)) + 1))}</textPath>
</text>
</g>
) : null}
</svg>
<div data-landed={isStatic || landed ? "true" : "false"} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
{plates.map((p, i) => (
<div
key={i}
data-plate={i}
ref={(el) => {
plateRefs.current[i] = el
}}
className="lyr-tt-plate"
style={{ width: front, height: front, pointerEvents: "auto" }}
>
<div className="lyr-tt-land" style={{ transitionDelay: `${i * 70}ms` }}>
<div
className="lyr-tt-spin"
ref={(el) => {
spinRefs.current[i] = el
}}
style={{ padding: rim ? `${rim}%` : 0, background: rim ? rimColor : "transparent" }}
>
{p.image?.src ? <img src={p.image.src} srcSet={p.image.srcSet} sizes={`${Math.round(front)}px`} alt={p.image.alt || p.title} draggable={false} /> : <i style={{ background: EMPTY[i % EMPTY.length] }} />}
</div>
</div>
</div>
))}
</div>
<div
style={{
position: "absolute",
left: lx,
top: ly,
width: Math.min(R * 0.72, 360),
transform: centered ? "translateX(-50%)" : undefined,
textAlign: centered ? "center" : "left",
zIndex: 8,
}}
aria-live="polite"
>
<div key={`${at}-${current.title}`} className="lyr-tt-swap">
<div style={{ fontSize: 15 * Math.max(0.9, k), fontWeight: 500, color: mutedColor, lineHeight: 1.2 }}>
{pad(at + 1)} / {pad(count)}
{current.note ? ` · ${current.note}` : ""}
</div>
<div style={{ fontFamily: display, fontSize: titleSize * k, fontWeight: titleWeight, letterSpacing: "-0.025em", lineHeight: 1.12, margin: `${8 * k}px 0 ${14 * k}px`, textWrap: "balance" } as CSSProperties}>{current.title}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, justifyContent: centered ? "center" : "flex-start" }}>
{showArrows && (
<>
<button type="button" className="lyr-tt-btn" aria-label="Previous" onClick={() => turn(-1)}>
<Arrow flip />
</button>
<button type="button" className="lyr-tt-btn lyr-tt-fill" aria-label="Next" onClick={() => turn(1)}>
<Arrow />
</button>
</>
)}
{current.price ? (
<span key={`${at}-${current.title}`} className="lyr-tt-swap" style={{ fontSize: 17, fontWeight: 400, marginLeft: showArrows ? 8 : 0, fontVariantNumeric: "tabular-nums" }}>
{currency}
{current.price}
</span>
) : null}
</div>
</div>
{cursorLabel && !isStatic ? (
<div ref={cursor} className="lyr-tt-cursor" aria-hidden="true">
{cursorLabel}
</div>
) : null}
</div>
)
}
function Arrow({ flip }: { flip?: boolean }) {
return (
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true" style={{ transform: flip ? "scaleX(-1)" : undefined }}>
<path d="M3 9h12M10 4l5 5-5 5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
addPropertyControls(Turntable, {
items: {
type: ControlType.Array,
title: "Items",
maxCount: 12,
control: {
type: ControlType.Object,
controls: {
image: { type: ControlType.ResponsiveImage, title: "Picture" },
title: { type: ControlType.String, title: "Title", defaultValue: "" },
note: { type: ControlType.String, title: "Note", defaultValue: "", description: "A short line above the title." },
price: { type: ControlType.String, title: "Price", defaultValue: "", description: "Shown after the arrows. Any short detail works." },
},
},
defaultValue: ITEMS,
},
centerX: { type: ControlType.Number, title: "Centre X", defaultValue: 82, min: -50, max: 150, unit: "%", step: 1 },
centerY: { type: ControlType.Number, title: "Centre Y", defaultValue: 89, min: -50, max: 200, unit: "%", step: 1 },
radiusByWidth: { type: ControlType.Number, title: "Radius of width", defaultValue: 36, min: 10, max: 120, unit: "%", step: 1 },
radiusByHeight: { type: ControlType.Number, title: "Radius of height", defaultValue: 56, min: 0, max: 150, unit: "%", step: 1, description: "The smaller of the two wins. 0 ignores height." },
frontAngle: { type: ControlType.Number, title: "Front angle", defaultValue: -120, min: -180, max: 180, unit: "°", step: 1 },
frontSize: { type: ControlType.Number, title: "Front plate", defaultValue: 80, min: 30, max: 120, unit: "%", step: 1 },
plateSize: { type: ControlType.Number, title: "Other plates", defaultValue: 47, min: 20, max: 100, unit: "%", step: 1 },
labelX: { type: ControlType.Number, title: "Label X", defaultValue: -34, min: -150, max: 150, unit: "%", step: 1 },
labelY: { type: ControlType.Number, title: "Label Y", defaultValue: -39, min: -150, max: 150, unit: "%", step: 1 },
labelAlign: { type: ControlType.Enum, title: "Label align", options: ["left", "center"], optionTitles: ["Left", "Center"], defaultValue: "left", displaySegmentedControl: true },
ringText: { type: ControlType.String, title: "Ring text", defaultValue: "", placeholder: "Turn the table · " },
showOrbit: { type: ControlType.Boolean, title: "Orbit line", defaultValue: false },
cursorLabel: { type: ControlType.String, title: "Cursor label", defaultValue: "Drag", description: "Shown in a circle that follows the pointer. Leave empty for the normal cursor." },
showArrows: { type: ControlType.Boolean, title: "Arrows", defaultValue: true },
currency: { type: ControlType.String, title: "Currency", defaultValue: "", placeholder: "€", description: "Written before the price." },
autoplay: { type: ControlType.Number, title: "Auto turn", defaultValue: 5, min: 0, max: 20, unit: "s", step: 0.5, description: "0 switches it off." },
scrollTurn: { type: ControlType.Number, title: "Scroll turn", defaultValue: 1.2, min: -4, max: 4, step: 0.1, description: "Plates turned per 1000px of page scroll." },
rim: { type: ControlType.Number, title: "Plate rim", defaultValue: 0, min: 0, max: 16, unit: "%", step: 1 },
rimColor: { type: ControlType.Color, title: "Rim", defaultValue: "#FBF8F2", hidden: (p: Partial<TurntableProps>) => !p.rim },
discColor: { type: ControlType.Color, title: "Table", defaultValue: "#E8E0D0" },
lineColor: { type: ControlType.Color, title: "Lines", defaultValue: "rgba(27,26,23,0.2)" },
textColor: { type: ControlType.Color, title: "Text", defaultValue: "#1B1A17" },
mutedColor: { type: ControlType.Color, title: "Muted text", defaultValue: "#8A8375" },
buttonColor: { type: ControlType.Color, title: "Button", defaultValue: "#1B1A17" },
buttonTextColor: { type: ControlType.Color, title: "Button icon", defaultValue: "#F2EDE3" },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
displayFamily: { type: ControlType.String, title: "Display font", defaultValue: "", placeholder: "Same as font family", description: "Family for the large type. Leave empty to use the font family." },
titleSize: { type: ControlType.Number, title: "Title size", defaultValue: 28, min: 14, max: 64, unit: "px", step: 1 },
titleWeight: { type: ControlType.Number, title: "Title weight", defaultValue: 400, min: 300, max: 800, step: 100 },
})
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.