Layr Components

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
82%
89%
36%
56%
-120 °
80%
47%
-34%
-39%
5 s
1.2
0%
#FFFFFF
#F5F4F1
#E6E5E1
#0E0E0D
#6E6E6B
#0E0E0D
#FFFFFF
28 px
400

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.