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
#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.