Layr Components

Components / Hero

Switcher

App screens as an app-switcher deck pinned in the hero; scrolling flicks each card away and the last one lands in a row of screens below.

Scroll insideFull screen
Props
54%
32 px
92 px
60 px
720 px
60%
#0E0E0D
#A3A3A0
#FFFFFF

Set in code: Screens, App icon, Button link, Second link. The preview fills these in with demo content.

Scroll inside the preview, or click a card behind.

In Framer, open Assets, add a new code file, paste this over everything in it and save. Switcher shows up in the Insert menu, and its controls appear in the right panel.
// Switcher · Layr Components
//
// An app's screens as a deck of cards, the way the app switcher shows them,
// pinned in the hero beside its headline. Scrolling flicks the front card off
// to the right and brings the next one forward with its own caption; click a
// card behind to jump to it. After the last card the deck lets go, and that
// card flies down into its place in a row of every screen below.
// 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 cards play. Leave Top gap free for the navigation. Without a screenshot a card shows a plain tone.

import { addPropertyControls, ControlType, Link, useIsStaticRenderer } from "framer"
import { useEffect, useLayoutEffect, useRef, type CSSProperties, type MouseEvent } from "react"

type Picture = { src?: string; srcSet?: string; alt?: string }
type Screen = { image?: Picture; imageDark?: Picture; name?: string; line?: string; lineSoft?: string; text?: string; link?: string }

interface SwitcherProps {
    items: Screen[]
    icon?: Picture
    heroLine: string
    heroLineSoft: string
    heroText: string
    buttonLabel: string
    buttonLink?: string
    secondLabel: string
    secondLink?: string
    linkLabel: string
    hint: string
    landing: boolean
    rowLine: string
    rowLineSoft: string
    rowText: string
    share: number
    edge: number
    topGap: number
    bottomGap: number
    cardMax: number
    step: number
    entrance: boolean
    imageWidth: string
    fontFamily: string
    textColour: string
    softColour: string
    groundColour: string
    style?: CSSProperties
}

const SCREENS: Screen[] = [
    { name: "Home", line: "Today at a glance,", lineSoft: "before your coffee.", text: "The screen most people open first: what is due, what changed and what to do next." },
    { name: "Plans", line: "Plans that keep", lineSoft: "themselves up to date.", text: "Set it once. Changes ripple through on their own, so nothing goes stale." },
    { name: "Inbox", line: "Only what needs you,", lineSoft: "nothing else.", text: "Updates are grouped by what you can act on, so the list stays short." },
    { name: "Progress", line: "The week", lineSoft: "as one line.", text: "Where you are against the plan, drawn as a single line. No dashboards to read." },
    { name: "Settings", line: "Two minutes to set up,", lineSoft: "then left alone.", text: "Connect what you already use and choose when it should check in." },
]

const BEHIND = 4 // cards that stay visible behind the front one
const RATIO = 0.93 // each card behind is this much smaller
const KEY = "lyr-switcher"
const SEEN_ATTR = "data-lyr-sw-seen"

const SHEET = `
.lyr-sw{position:relative;z-index:1;width:100%;--win:100vh;--tall:100vh;color:var(--ink);-webkit-font-smoothing:antialiased}
@supports (height:100svh){.lyr-sw{--win:100svh;--tall:100lvh}}
.lyr-sw-track{position:relative;height:calc(var(--tall) + (var(--n) - 1) * var(--step) * var(--win) / 100 + var(--win) * 0.12);pointer-events:none}
.lyr-sw-pin{position:sticky;top:0;height:var(--tall);container-type:inline-size;overflow-x:clip}
.lyr-sw-box{position:absolute;left:0;right:0;top:0;height:var(--win);
--head:0px;--lab:42px;
--deck:calc(var(--share) * 1cqw);
--avail:calc(var(--win) - var(--top) - var(--head) - var(--lab) - var(--bottom));
--ch:max(180px,min(var(--avail),var(--max),calc((var(--deck) - var(--edge) * 2) * 1.08)));
--cw:calc(var(--ch) * 393 / 852);
--span:min(calc(var(--deck) - var(--edge) * 2 - var(--cw)),calc(var(--cw) * 1.5));
--y:calc(var(--top) + var(--head) + max(0px,(var(--avail) - var(--ch)) / 2))}
.lyr-sw-item{position:absolute;inset:0}
.lyr-sw-label{position:absolute;right:var(--edge);top:var(--y);width:var(--cw);height:var(--lab);display:flex;align-items:flex-start;gap:9px;font-size:15px;line-height:22px;font-weight:500;letter-spacing:-0.015em;white-space:nowrap;opacity:var(--lo,0)}
.lyr-sw-icon{width:22px;height:22px;border-radius:24%;object-fit:cover;display:block;flex:none}
.lyr-sw-card{position:absolute;right:var(--edge);top:calc(var(--y) + var(--lab));width:var(--cw);height:var(--ch);display:block;border-radius:calc(var(--cw) * 0.155);overflow:hidden;background:var(--ground);box-shadow:0 0 0 1px rgba(12,14,17,.05),0 28px 56px -34px rgba(12,14,17,.16);transform-origin:0 50%;transform:translate3d(calc(var(--span) * -1 * var(--f,0) + (var(--cw) + var(--edge) + 80px) * var(--out,0)),0,0) scale(var(--s,1));opacity:var(--o,1);pointer-events:auto;cursor:pointer;will-change:transform;color:inherit;-webkit-tap-highlight-color:transparent}
.lyr-sw-hit{position:absolute;inset:0;display:block}
.lyr-sw-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:50% 0;display:block}
.lyr-sw-empty{background:linear-gradient(165deg,#ecebe7,#d3d1cb)}
.lyr-sw-dk .lyr-sw-empty{background:linear-gradient(165deg,#2c2d30,#17181a)}
.lyr-sw:not(.lyr-sw-dk) .lyr-sw-dark,.lyr-sw-dk .lyr-sw-light{display:none!important}
.lyr-sw-dim{position:absolute;inset:0;background:var(--ground);opacity:var(--dim,0);transition:opacity .2s ease}
.lyr-sw-card:hover .lyr-sw-dim{opacity:calc(var(--dim,0) * 0.55)}
.lyr-sw-hero{position:absolute;left:var(--edge);top:var(--top);bottom:var(--bottom);width:min(calc(100cqw - var(--deck) - var(--edge) * 2),560px);display:flex;flex-direction:column;justify-content:space-between;gap:24px;pointer-events:auto;transition:opacity .25s ease}
.lyr-sw-away .lyr-sw-hero{opacity:0;pointer-events:none}
.lyr-sw-cap{position:absolute;left:var(--edge);bottom:var(--bottom);width:min(calc(100cqw - var(--deck) - var(--edge) * 2),460px);opacity:0;transition:opacity .25s ease;pointer-events:none}
.lyr-sw-on .lyr-sw-cap{opacity:1;transition:opacity .4s ease .16s;pointer-events:auto}
.lyr-sw-htitle{font-size:76px;line-height:1;letter-spacing:-0.05em;font-weight:400;margin:0;text-wrap:balance}
.lyr-sw-htitle span,.lyr-sw-line span,.lyr-sw-rowline span,.lyr-sw-sline span{color:var(--soft)}
.lyr-sw-name{font-size:15px;line-height:1.3;letter-spacing:-0.01em;color:var(--soft);margin:0 0 14px}
.lyr-sw-line{font-size:56px;line-height:1.02;letter-spacing:-0.045em;font-weight:400;margin:0;text-wrap:balance}
.lyr-sw-text{font-size:17px;line-height:1.45;letter-spacing:-0.014em;margin:20px 0 0;max-width:380px;opacity:.72;text-wrap:pretty}
.lyr-sw-hero .lyr-sw-text{margin:0}
.lyr-sw-btns{display:flex;flex-wrap:wrap;align-items:center;gap:14px 26px;margin-top:26px}
.lyr-sw-btn{display:inline-flex;align-items:center;height:48px;padding:0 24px;border-radius:999px;background:var(--ink);color:var(--ground);font-size:15px;letter-spacing:-0.012em;text-decoration:none;white-space:nowrap;transition:opacity .2s}
a.lyr-sw-btn:hover{opacity:.82}
.lyr-sw-link{display:inline-block;margin-top:24px;font-size:15px;line-height:1.3;letter-spacing:-0.012em;color:inherit;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:5px;transition:opacity .2s}
.lyr-sw-btns .lyr-sw-link{margin-top:0}
a.lyr-sw-link:hover{opacity:.62}
.lyr-sw-count{position:absolute;right:var(--edge);bottom:calc(var(--bottom) - 34px);font-size:14px;line-height:1.3;letter-spacing:-0.01em;color:var(--soft);font-variant-numeric:tabular-nums;white-space:nowrap}
.lyr-sw-probe{position:absolute;width:0;height:0;overflow:hidden;background:var(--ground);pointer-events:none}
.lyr-sw-row{position:relative;z-index:0;padding:clamp(56px,9vw,128px) var(--edge) clamp(40px,6vw,80px)}
.lyr-sw-rowhead{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:flex-end;gap:20px 48px;margin-bottom:clamp(28px,4vw,56px)}
.lyr-sw-rowline{font-size:56px;line-height:1.02;letter-spacing:-0.045em;font-weight:400;margin:0;max-width:640px;text-wrap:balance}
.lyr-sw-rowtext{font-size:17px;line-height:1.45;letter-spacing:-0.014em;margin:0;max-width:380px;opacity:.72;text-wrap:pretty}
.lyr-sw-slots{display:grid;grid-template-columns:repeat(var(--n),minmax(0,1fr));gap:clamp(14px,2vw,28px);max-width:calc(var(--n) * 300px)}
.lyr-sw-slot{display:block;min-width:0;color:inherit;text-decoration:none}
a.lyr-sw-slot{transition:opacity .2s}
a.lyr-sw-slot:hover{opacity:.78}
.lyr-sw-shot{position:relative;width:100%;aspect-ratio:393/852;border-radius:15.5%/7.15%;overflow:hidden;background:var(--ground);box-shadow:0 0 0 1px rgba(12,14,17,.05)}
.lyr-sw-sname{font-size:22px;line-height:1.2;letter-spacing:-0.03em;margin:18px 0 0}
.lyr-sw-sline{font-size:15px;line-height:1.35;letter-spacing:-0.01em;margin:6px 0 0}
.lyr-sw-static .lyr-sw-track{height:var(--tall)}
.lyr-sw-static .lyr-sw-pin{position:relative}
@keyframes lyr-sw-in{from{transform:translate3d(calc(60cqw + var(--cw)),0,0) scale(var(--s,1));opacity:0}}
@keyframes lyr-sw-veil{to{opacity:0;visibility:hidden}}
@keyframes lyr-sw-show{from{opacity:0}}
.lyr-sw-intro .lyr-sw-card{animation:lyr-sw-in .52s cubic-bezier(.2,.8,.2,1) backwards;animation-delay:calc(var(--d) * 85ms)}
.lyr-sw-intro .lyr-sw-hero{animation:lyr-sw-show .3s ease .62s backwards}
.lyr-sw-veil{position:fixed;inset:0;background:var(--ground);z-index:-1;pointer-events:none;animation:lyr-sw-veil .3s ease .62s both}
html[${SEEN_ATTR}] .lyr-sw-intro .lyr-sw-card,html[${SEEN_ATTR}] .lyr-sw-intro .lyr-sw-hero{animation:none}
html[${SEEN_ATTR}] .lyr-sw-veil{display:none}
@media (min-width:810px) and (max-width:1199.98px){.lyr-sw-htitle{font-size:54px}.lyr-sw-line,.lyr-sw-rowline{font-size:42px}.lyr-sw-text,.lyr-sw-rowtext{font-size:16px}}
@media (max-width:809.98px){
.lyr-sw-box{--head:var(--lyr-sw-head,300px);--lab:34px;--deck:100cqw}
.lyr-sw-hero{left:var(--edge);right:var(--edge);bottom:auto;width:auto;display:block;padding-bottom:18px}
.lyr-sw-cap{left:var(--edge);right:var(--edge);top:var(--top);bottom:auto;width:auto;height:var(--head);box-sizing:border-box;display:flex;flex-direction:column;justify-content:flex-end;padding-bottom:18px}
.lyr-sw-htitle{font-size:40px}.lyr-sw-line,.lyr-sw-rowline{font-size:34px}.lyr-sw-name{margin-bottom:10px}.lyr-sw-text,.lyr-sw-rowtext{font-size:16px}.lyr-sw-cap .lyr-sw-text{margin-top:12px}.lyr-sw-hero .lyr-sw-text{margin-top:16px}.lyr-sw-link{margin-top:14px}.lyr-sw-btns{margin-top:18px}
.lyr-sw-label{font-size:14px}
.lyr-sw-count{bottom:calc(var(--bottom) - 26px);font-size:13px}
.lyr-sw-slots{grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:58%;max-width:none;overflow-x:auto;scroll-snap-type:x mandatory;scroll-padding-inline:var(--edge);margin:0 calc(var(--edge) * -1);padding:0 var(--edge);scrollbar-width:none}
.lyr-sw-slots::-webkit-scrollbar{display:none}
.lyr-sw-slot{scroll-snap-align:start}
.lyr-sw-sname{font-size:19px;margin-top:14px}
}
@media (prefers-reduced-motion:reduce){.lyr-sw-intro .lyr-sw-card,.lyr-sw-intro .lyr-sw-hero{animation:none}.lyr-sw-veil{display:none}.lyr-sw-track{height:var(--tall)}.lyr-sw-cap,.lyr-sw-dim,.lyr-sw-hero{transition:none}}
`

// Runs as the page is parsed: on a later page of the same visit the entrance is switched off before it can paint.
const SEEN = `(function(){try{var w=window,h=document.documentElement;if(w.__lyrSwPre)return;if(sessionStorage.getItem("${KEY}")){h.setAttribute("${SEEN_ATTR}","");w.__lyrSwPre="seen"}else{sessionStorage.setItem("${KEY}","1");w.__lyrSwPre="first"}}catch(e){document.documentElement.setAttribute("${SEEN_ATTR}","")}})()`

const clamp = (v: number, a = 0, b = 1) => Math.min(b, Math.max(a, v))
const ease = (t: number) => t * t * (3 - 2 * t)
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
const has = (s?: string) => Boolean(s && s.trim())

// Where a card sits for a position in the deck (k = 0 is the front, below 0 it is leaving to the right).
function pose(k: number) {
    if (k >= 0) {
        const kk = Math.min(k, BEHIND)
        return {
            f: (1 - Math.pow(RATIO, kk)) / (1 - Math.pow(RATIO, BEHIND)),
            s: Math.pow(RATIO, kk),
            out: 0,
            o: k > BEHIND ? clamp(1 - (k - BEHIND)) : 1,
            dim: Math.min(0.62, 0.17 * k),
            lo: clamp(1 - k * 2.4),
        }
    }
    return { f: 0, s: 1, out: Math.min(1.05, -k), o: -k >= 1 ? 0 : 1, dim: 0, lo: clamp(1 + k * 2.4) }
}

// How light a colour is (0 to 1), whatever syntax the page wrote it in; -1 when it is see-through.
function lightness(colour: string) {
    const c = document.createElement("canvas")
    c.width = c.height = 1
    const x = c.getContext("2d", { willReadFrequently: true } as CanvasRenderingContext2DSettings)
    if (!x) return -1
    x.fillStyle = colour
    x.fillRect(0, 0, 1, 1)
    const [r, g, b, a] = x.getImageData(0, 0, 1, 1).data
    if (a < 128) return -1
    const f = (v: number) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : Math.pow((v / 255 + 0.055) / 1.055, 2.4))
    return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
}

type Deck = { goal: number; shown: number; active: number; raf: number; last: number; idle: number; reduce: boolean }

/**
 * @framerIntrinsicWidth 1200
 * @framerIntrinsicHeight 760
 *
 * @framerSupportedLayoutWidth any
 * @framerSupportedLayoutHeight auto
 */
export default function Switcher(props: SwitcherProps) {
    const {
        items = SCREENS,
        icon,
        heroLine = "The whole week,",
        heroLineSoft = "on one screen.",
        heroText = "Plans, updates and progress in one quiet app that tells you what to do next.",
        buttonLabel = "Download the app",
        buttonLink,
        secondLabel = "See what is new",
        secondLink,
        linkLabel = "See how it works",
        hint = "Scroll to flick through",
        landing = true,
        rowLine = "Every screen,",
        rowLineSoft = "side by side.",
        rowText = "No feeds and no clutter. Each screen answers one question.",
        share = 54,
        edge = 32,
        topGap = 92,
        bottomGap = 60,
        cardMax = 720,
        step = 60,
        entrance = true,
        imageWidth = "340px",
        fontFamily = "Inter",
        textColour = "#0C0E11",
        softColour = "#9A9FA6",
        groundColour = "#FFFFFF",
        style,
    } = props
    const isStatic = useIsStaticRenderer()
    const screens = (items || []).filter(Boolean)
    const n = Math.max(1, screens.length)
    const hero = has(heroLine) || has(heroLineSoft) || has(heroText) || has(buttonLabel) || has(secondLabel)

    const rootRef = useRef<HTMLDivElement>(null)
    const trackRef = useRef<HTMLDivElement>(null)
    const boxRef = useRef<HTMLDivElement>(null)
    const heroRef = useRef<HTMLDivElement>(null)
    const countRef = useRef<HTMLDivElement>(null)
    const veilRef = useRef<HTMLDivElement>(null)
    const probeRef = useRef<HTMLSpanElement>(null)
    const itemRefs = useRef<Array<HTMLDivElement | null>>([])
    const cardRefs = useRef<Array<HTMLDivElement | null>>([])
    const labelRefs = useRef<Array<HTMLDivElement | null>>([])
    const capRefs = useRef<Array<HTMLDivElement | null>>([])
    const shotRefs = useRef<Array<HTMLDivElement | null>>([])
    const deckRef = useRef<Deck | null>(null)

    const countText = (active: number) => `${active + 1} of ${screens.length}${active === 0 && has(hint) ? `. ${hint}` : ""}`

    // The deck: follows the scroll with a little easing, and settles on the nearest card when scrolling rests.
    useIsoLayoutEffect(() => {
        const root = rootRef.current,
            track = trackRef.current,
            box = boxRef.current
        if (isStatic || !root || !track || !box) return
        const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
        const d: Deck = { goal: 0, shown: 0, active: -1, raf: 0, last: 0, idle: 0, reduce: mq.matches }
        deckRef.current = d

        const measure = () => {
            if (d.reduce || track.offsetHeight <= 0) return 0
            const stepPx = (box.offsetHeight * step) / 100
            if (stepPx <= 0) return 0
            return clamp(-track.getBoundingClientRect().top / stepPx, 0, n - 1)
        }

        const paintCard = (idx: number, active: number) => {
            const el = cardRefs.current[idx]
            if (!el) return
            const p = pose(idx - d.shown)
            const st = el.style
            st.setProperty("--f", p.f.toFixed(4))
            st.setProperty("--s", p.s.toFixed(4))
            st.setProperty("--out", p.out.toFixed(4))
            st.setProperty("--o", p.o.toFixed(3))
            st.setProperty("--dim", p.dim.toFixed(3))
            st.visibility = p.o <= 0 ? "hidden" : "visible"
            let lo = p.lo
            if (idx === n - 1 && landing && !d.reduce) {
                // the last card leaves the deck for its place in the row below
                const slot = shotRefs.current[idx]
                const pin = box.parentElement
                let dd = 0
                if (slot && pin && d.shown > n - 1.02) {
                    const sr = slot.getBoundingClientRect()
                    const vh = window.innerHeight
                    if (sr.right <= window.innerWidth + 1 && sr.left >= -1) dd = ease(clamp((vh - sr.top) / (vh * 0.62)))
                    if (dd > 0) {
                        const pr = pin.getBoundingClientRect()
                        const w = el.offsetWidth
                        const h = el.offsetHeight
                        const sc = 1 + (sr.width / w - 1) * dd
                        const tx = (sr.left - pr.left - el.offsetLeft) * dd
                        const ty = (sr.top - pr.top - el.offsetTop - (h * (1 - sr.width / w)) / 2) * dd
                        st.transform = `translate3d(${tx.toFixed(2)}px,${ty.toFixed(2)}px,0) scale(${sc.toFixed(4)})`
                        st.boxShadow = `0 0 0 1px rgba(12,14,17,.05),0 28px 56px -34px rgba(12,14,17,${(0.16 * (1 - dd)).toFixed(3)})`
                        st.visibility = dd >= 1 ? "hidden" : "visible"
                        lo = Math.min(lo, 1 - dd * 3)
                    }
                }
                if (dd <= 0 && st.transform) {
                    st.transform = ""
                    st.boxShadow = ""
                }
                if (slot) slot.style.opacity = dd > 0 && dd < 1 ? "0" : ""
            }
            labelRefs.current[idx]?.style.setProperty("--lo", Math.max(0, lo).toFixed(3))
            itemRefs.current[idx]?.classList.toggle("lyr-sw-on", active === idx)
        }

        const paintAll = () => {
            const active = clamp(Math.round(d.shown), 0, n - 1)
            for (let i = 0; i < n; i++) paintCard(i, active)
            if (active !== d.active) {
                d.active = active
                root.classList.toggle("lyr-sw-away", active > 0)
                if (countRef.current) countRef.current.textContent = countText(active)
            }
        }

        const run = () => {
            if (d.raf) return
            d.last = 0
            const tick = (now: number) => {
                const dt = d.last ? Math.min(64, now - d.last) : 16
                d.last = now
                const gap = d.goal - d.shown
                if (Math.abs(gap) < 0.0006) d.shown = d.goal
                else d.shown += gap * (1 - Math.exp(-dt / 110))
                paintAll()
                d.raf = d.shown !== d.goal ? requestAnimationFrame(tick) : 0
            }
            d.raf = requestAnimationFrame(tick)
        }

        const onScroll = () => {
            d.goal = measure()
            run()
            window.clearTimeout(d.idle)
            // once scrolling rests, let the nearest card settle at the front
            d.idle = window.setTimeout(() => {
                const raw = measure()
                if (raw > 0 && raw < n - 1) {
                    d.goal = Math.round(raw)
                    run()
                }
            }, 170)
        }
        const onMotion = () => {
            d.reduce = mq.matches
            onScroll()
        }

        window.addEventListener("scroll", onScroll, { passive: true })
        window.addEventListener("resize", onScroll)
        mq.addEventListener?.("change", onMotion)
        d.goal = measure()
        d.shown = d.goal
        paintAll()
        return () => {
            window.removeEventListener("scroll", onScroll)
            window.removeEventListener("resize", onScroll)
            mq.removeEventListener?.("change", onMotion)
            window.clearTimeout(d.idle)
            if (d.raf) cancelAnimationFrame(d.raf)
            deckRef.current = null
        }
    }, [isStatic, n, step, landing, hint])

    // On phones the text sits above the deck: keep room for the tallest of the hero and the captions.
    useIsoLayoutEffect(() => {
        const box = boxRef.current
        if (!box) return
        const size = () => {
            let h = heroRef.current ? heroRef.current.offsetHeight : 0
            for (const c of capRefs.current) if (c) h = Math.max(h, c.offsetHeight + 18)
            if (h > 0) box.style.setProperty("--lyr-sw-head", `${Math.ceil(h)}px`)
        }
        size()
        if (typeof ResizeObserver === "undefined") return
        const ro = new ResizeObserver(size)
        if (heroRef.current) ro.observe(heroRef.current)
        for (const c of capRefs.current) if (c) ro.observe(c)
        return () => ro.disconnect()
    }, [n, hero])

    // Dark screenshots show when the page colour is dark, also when a theme switch changes it.
    useIsoLayoutEffect(() => {
        const root = rootRef.current,
            probe = probeRef.current
        if (!root || !probe) return
        const check = () => {
            const l = lightness(getComputedStyle(probe).backgroundColor)
            root.classList.toggle("lyr-sw-dk", l >= 0 && l < 0.3)
        }
        check()
        const mq = window.matchMedia("(prefers-color-scheme: dark)")
        const later = () => window.setTimeout(check, 60)
        mq.addEventListener?.("change", later)
        const mo = typeof MutationObserver !== "undefined" ? new MutationObserver(check) : null
        mo?.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style", "data-theme", "data-framer-theme"] })
        if (document.body) mo?.observe(document.body, { attributes: true, attributeFilter: ["class", "style", "data-theme"] })
        return () => {
            mq.removeEventListener?.("change", later)
            mo?.disconnect()
        }
    }, [groundColour])

    // The entrance plays once per visit; the veil is cleared away when it has faded.
    useIsoLayoutEffect(() => {
        if (isStatic || !entrance) return
        const w = window as any
        const h = document.documentElement
        if (w.__lyrSwDone) h.setAttribute(SEEN_ATTR, "")
        else if (!w.__lyrSwPre) {
            try {
                if (sessionStorage.getItem(KEY)) {
                    h.setAttribute(SEEN_ATTR, "")
                    w.__lyrSwPre = "seen"
                } else {
                    sessionStorage.setItem(KEY, "1")
                    w.__lyrSwPre = "first"
                }
            } catch (e) {
                h.setAttribute(SEEN_ATTR, "")
            }
        }
        const v = veilRef.current
        if (!v) return
        const gone = () => {
            w.__lyrSwDone = true
            v.style.display = "none"
        }
        const cs = getComputedStyle(v)
        if (cs.display === "none" || cs.visibility === "hidden") {
            gone()
            return
        }
        v.addEventListener("animationend", gone)
        const t = window.setTimeout(gone, 1600)
        return () => {
            v.removeEventListener("animationend", gone)
            window.clearTimeout(t)
        }
    }, [isStatic, entrance])

    // A card behind the front one: bring it forward instead of following its link.
    const toFront = (idx: number) => (e: MouseEvent) => {
        const d = deckRef.current,
            track = trackRef.current,
            box = boxRef.current
        if (!d || !track || !box) return
        if (clamp(Math.round(d.shown), 0, n - 1) === idx) return
        e.preventDefault()
        e.stopPropagation()
        const top = track.getBoundingClientRect().top + window.scrollY
        window.scrollTo({ top: top + (idx * box.offsetHeight * step) / 100 + 1, behavior: d.reduce ? "auto" : "smooth" })
    }

    const go = (href: string | undefined, cls: string, label: string) =>
        href ? (
            <Link href={href}>
                <a className={cls}>{label}</a>
            </Link>
        ) : (
            <span className={cls}>{label}</span>
        )

    const pictures = (s: Screen, eager: boolean) => {
        const alt = s.image?.alt || s.imageDark?.alt || s.name || ""
        if (!s.image?.src && !s.imageDark?.src) return <div className="lyr-sw-img lyr-sw-empty" />
        return (
            <>
                {s.image?.src ? <img className={s.imageDark?.src ? "lyr-sw-img lyr-sw-light" : "lyr-sw-img"} loading={eager ? "eager" : "lazy"} decoding="async" srcSet={s.image.srcSet} sizes={imageWidth} src={s.image.src} alt={alt} draggable={false} /> : null}
                {s.imageDark?.src ? <img className={s.image?.src ? "lyr-sw-img lyr-sw-dark" : "lyr-sw-img"} loading="lazy" decoding="async" srcSet={s.imageDark.srcSet} sizes={imageWidth} src={s.imageDark.src} alt={alt} draggable={false} /> : null}
            </>
        )
    }

    const vars = {
        "--n": n,
        "--step": step,
        "--share": share,
        "--edge": `${edge}px`,
        "--top": `${topGap}px`,
        "--bottom": `${bottomGap}px`,
        "--max": `${cardMax}px`,
        "--ink": textColour,
        "--soft": softColour,
        "--ground": groundColour,
        ...(isStatic ? { "--win": "760px", "--tall": "760px" } : {}),
    } as CSSProperties
    const intro = entrance && !isStatic
    const cls = `lyr-sw${isStatic ? " lyr-sw-static" : ""}${intro ? " lyr-sw-intro" : ""}`

    return (
        <div ref={rootRef} className={cls} style={{ ...style, ...vars, fontFamily: `"${fontFamily}", "Inter", system-ui, sans-serif` }}>
            <style dangerouslySetInnerHTML={{ __html: SHEET }} />
            {intro ? <script dangerouslySetInnerHTML={{ __html: SEEN }} /> : null}
            {intro ? <div ref={veilRef} className="lyr-sw-veil" /> : null}
            <span ref={probeRef} className="lyr-sw-probe" aria-hidden="true" />
            <div ref={trackRef} className="lyr-sw-track">
                <div className="lyr-sw-pin">
                    <div ref={boxRef} className="lyr-sw-box">
                        {hero ? (
                            <div ref={heroRef} className="lyr-sw-hero">
                                <p className="lyr-sw-htitle">
                                    {heroLine} {has(heroLineSoft) ? <span>{heroLineSoft}</span> : null}
                                </p>
                                <div>
                                    {has(heroText) ? <p className="lyr-sw-text">{heroText}</p> : null}
                                    {has(buttonLabel) || has(secondLabel) ? (
                                        <div className="lyr-sw-btns">
                                            {has(buttonLabel) ? go(buttonLink, "lyr-sw-btn", buttonLabel) : null}
                                            {has(secondLabel) ? go(secondLink, "lyr-sw-link", secondLabel) : null}
                                        </div>
                                    ) : null}
                                </div>
                            </div>
                        ) : null}
                        {screens.map((s, idx) => {
                            const start = pose(idx)
                            const showCaption = idx > 0 || !hero
                            return (
                                <div
                                    key={idx}
                                    ref={(el) => {
                                        itemRefs.current[idx] = el
                                    }}
                                    className={idx === 0 ? "lyr-sw-item lyr-sw-on" : "lyr-sw-item"}
                                    style={{ zIndex: n - idx }}
                                >
                                    <div
                                        ref={(el) => {
                                            labelRefs.current[idx] = el
                                        }}
                                        className="lyr-sw-label"
                                        style={{ "--lo": start.lo } as CSSProperties}
                                    >
                                        {icon?.src ? <img className="lyr-sw-icon" loading="eager" decoding="async" src={icon.src} alt="" /> : null}
                                        <span>{s.name}</span>
                                    </div>
                                    <div
                                        ref={(el) => {
                                            cardRefs.current[idx] = el
                                        }}
                                        className="lyr-sw-card"
                                        style={{ "--f": start.f.toFixed(4), "--s": start.s.toFixed(4), "--out": 0, "--o": start.o, "--dim": start.dim.toFixed(3), "--d": n - 1 - idx } as CSSProperties}
                                        onClickCapture={isStatic ? undefined : toFront(idx)}
                                    >
                                        {pictures(s, idx <= BEHIND)}
                                        <div className="lyr-sw-dim" />
                                        {s.link ? (
                                            <Link href={s.link}>
                                                <a className="lyr-sw-hit" aria-label={s.name || linkLabel} />
                                            </Link>
                                        ) : null}
                                    </div>
                                    {showCaption ? (
                                        <div className="lyr-sw-cap">
                                            <div
                                                ref={(el) => {
                                                    capRefs.current[idx] = el
                                                }}
                                            >
                                                <p className="lyr-sw-name">{s.name}</p>
                                                <p className="lyr-sw-line">
                                                    {s.line} {has(s.lineSoft) ? <span>{s.lineSoft}</span> : null}
                                                </p>
                                                {has(s.text) ? <p className="lyr-sw-text">{s.text}</p> : null}
                                                {s.link && has(linkLabel) ? (
                                                    <Link href={s.link}>
                                                        <a className="lyr-sw-link">{linkLabel}</a>
                                                    </Link>
                                                ) : null}
                                            </div>
                                        </div>
                                    ) : null}
                                </div>
                            )
                        })}
                        <div ref={countRef} className="lyr-sw-count" aria-hidden="true">
                            {screens.length ? countText(0) : ""}
                        </div>
                    </div>
                </div>
            </div>
            {landing ? (
                <div className="lyr-sw-row">
                    {has(rowLine) || has(rowLineSoft) || has(rowText) ? (
                        <div className="lyr-sw-rowhead">
                            <p className="lyr-sw-rowline">
                                {rowLine} {has(rowLineSoft) ? <span>{rowLineSoft}</span> : null}
                            </p>
                            {has(rowText) ? <p className="lyr-sw-rowtext">{rowText}</p> : null}
                        </div>
                    ) : null}
                    <div className="lyr-sw-slots">
                        {screens.map((s, idx) => {
                            const inner = (
                                <>
                                    <div
                                        ref={(el) => {
                                            shotRefs.current[idx] = el
                                        }}
                                        className="lyr-sw-shot"
                                    >
                                        {pictures(s, false)}
                                    </div>
                                    <p className="lyr-sw-sname">{s.name}</p>
                                    {has(s.line) || has(s.lineSoft) ? (
                                        <p className="lyr-sw-sline">
                                            {s.line} {has(s.lineSoft) ? <span>{s.lineSoft}</span> : null}
                                        </p>
                                    ) : null}
                                </>
                            )
                            return s.link ? (
                                <Link key={idx} href={s.link}>
                                    <a className="lyr-sw-slot">{inner}</a>
                                </Link>
                            ) : (
                                <div key={idx} className="lyr-sw-slot">
                                    {inner}
                                </div>
                            )
                        })}
                    </div>
                </div>
            ) : null}
        </div>
    )
}

const noRow = (p: Partial<SwitcherProps>) => p.landing === false

addPropertyControls(Switcher, {
    items: {
        type: ControlType.Array,
        title: "Screens",
        maxCount: 10,
        description: "One card per screen, front card first. The last one lands in the row below.",
        control: {
            type: ControlType.Object,
            controls: {
                image: { type: ControlType.ResponsiveImage, title: "Screenshot", description: "A portrait screenshot of the app, 393 by 852 points or the same shape." },
                imageDark: { type: ControlType.ResponsiveImage, title: "Dark screenshot", description: "Optional. The same screen in the app's dark theme, shown when the page colour is dark." },
                name: { type: ControlType.String, title: "Name", defaultValue: "", description: "The screen's name, shown above the card." },
                line: { type: ControlType.String, title: "Headline", defaultValue: "", description: "First part of the line shown beside the card." },
                lineSoft: { type: ControlType.String, title: "Headline, soft part", defaultValue: "", description: "Second part of the line, in the soft colour." },
                text: { type: ControlType.String, title: "Text", defaultValue: "", displayTextArea: true, description: "One or two sentences under the headline. Leave empty to hide." },
                link: { type: ControlType.Link, title: "Link", description: "Where the front card and its link go." },
            },
        },
        defaultValue: [
            { name: "Home", line: "Today at a glance,", lineSoft: "before your coffee.", text: "The screen most people open first: what is due, what changed and what to do next." },
            { name: "Plans", line: "Plans that keep", lineSoft: "themselves up to date.", text: "Set it once. Changes ripple through on their own, so nothing goes stale." },
            { name: "Inbox", line: "Only what needs you,", lineSoft: "nothing else.", text: "Updates are grouped by what you can act on, so the list stays short." },
            { name: "Progress", line: "The week", lineSoft: "as one line.", text: "Where you are against the plan, drawn as a single line. No dashboards to read." },
            { name: "Settings", line: "Two minutes to set up,", lineSoft: "then left alone.", text: "Connect what you already use and choose when it should check in." },
        ],
    },
    icon: { type: ControlType.ResponsiveImage, title: "App icon", description: "Shown beside the name above the front card." },
    heroLine: { type: ControlType.String, title: "Hero title", defaultValue: "The whole week,", description: "The hero text stands in for the first card's caption and steps aside when the second card comes forward. Leave the hero empty to show that caption instead." },
    heroLineSoft: { type: ControlType.String, title: "Hero title, soft part", defaultValue: "on one screen." },
    heroText: { type: ControlType.String, title: "Hero text", defaultValue: "Plans, updates and progress in one quiet app that tells you what to do next.", displayTextArea: true },
    buttonLabel: { type: ControlType.String, title: "Button label", defaultValue: "Download the app" },
    buttonLink: { type: ControlType.Link, title: "Button link" },
    secondLabel: { type: ControlType.String, title: "Second label", defaultValue: "See what is new", description: "An underlined link beside the button. Leave empty to hide." },
    secondLink: { type: ControlType.Link, title: "Second link" },
    linkLabel: { type: ControlType.String, title: "Link label", defaultValue: "See how it works", description: "The link under each card's caption, shown when the card has a link." },
    hint: { type: ControlType.String, title: "Hint", defaultValue: "Scroll to flick through", description: "Small note beside the count while the first card is in front. Leave empty to hide." },
    landing: { type: ControlType.Boolean, title: "Landing", defaultValue: true, enabledTitle: "On", disabledTitle: "Off", description: "A row of every screen under the deck. The last card comes to rest on its place in the row." },
    rowLine: { type: ControlType.String, title: "Row title", defaultValue: "Every screen,", hidden: noRow },
    rowLineSoft: { type: ControlType.String, title: "Row title, soft part", defaultValue: "side by side.", hidden: noRow },
    rowText: { type: ControlType.String, title: "Row text", defaultValue: "No feeds and no clutter. Each screen answers one question.", displayTextArea: true, hidden: noRow },
    share: { type: ControlType.Number, title: "Deck share", defaultValue: 54, min: 30, max: 100, step: 1, unit: "%", description: "How much of the width the deck may use. Phones always use all of it." },
    edge: { type: ControlType.Number, title: "Side gap", defaultValue: 32, min: 0, max: 80, step: 1, unit: "px" },
    topGap: { type: ControlType.Number, title: "Top gap", defaultValue: 92, min: 0, max: 200, step: 1, unit: "px", description: "Room for the navigation." },
    bottomGap: { type: ControlType.Number, title: "Bottom gap", defaultValue: 60, min: 0, max: 200, step: 1, unit: "px" },
    cardMax: { type: ControlType.Number, title: "Largest card", defaultValue: 720, min: 240, max: 1000, step: 10, unit: "px", description: "The tallest the front card may get." },
    step: { type: ControlType.Number, title: "Scroll per card", defaultValue: 60, min: 20, max: 150, step: 5, unit: "%", description: "How far to scroll for each card, as a share of the window height." },
    entrance: { type: ControlType.Boolean, title: "Entrance", defaultValue: true, enabledTitle: "On", disabledTitle: "Off", description: "The cards slide into the deck when the page first opens, once per visit." },
    imageWidth: { type: ControlType.String, title: "Image width", defaultValue: "340px", description: "One plain length such as 340px. Sets which image size loads." },
    fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter", description: "A font already used on the page." },
    textColour: { type: ControlType.Color, title: "Text colour", defaultValue: "#0C0E11" },
    softColour: { type: ControlType.Color, title: "Soft colour", defaultValue: "#9A9FA6" },
    groundColour: { type: ControlType.Color, title: "Page colour", defaultValue: "#FFFFFF", description: "Your page colour. Cards behind fade toward it, and when it is dark the dark screenshots show." },
})

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.