Components / Hero
Week Strip
Seven photo slats, one per day, with today open; click, arrow keys or the round arrows open another day. Phones get day tabs over a swipe card.
Props
#0E0E0D
#FFFFFF
#FF4A1C
Set in code: Days, Zone colours, Zone names. The preview fills these in with demo content.
Click a day, or use the arrows. On a phone, swipe the card.
In Framer, open Assets, add a new code file, paste this over everything in it and save. Week Strip shows up in the Insert menu, and its controls appear in the right panel.
// Week Strip · Layr Components
//
// The week as seven photo slats, one per day, with today open. A click, the
// arrow keys or the two round arrows open another day: the slats glide wider
// and narrower on one spring curve, then the words and the meters follow. On
// phones it becomes day tabs over one card you swipe.
// Paste into a new code file in Framer. Give it a fixed width; on wide layouts
// its height follows the width, on phones the card is Phone open tall. Add the
// days in the Days list, Monday first. Without a photo a day shows a dark slat
// tinted with its zone colour.
import * as React from "react"
import { addPropertyControls, ControlType, RenderTarget, useIsStaticRenderer } from "framer"
interface WeekImage {
src: string
srcSet?: string
alt?: string
}
interface WeekDay {
day: string
title: string
time?: string
duration?: string
person?: string
zone?: number
image?: WeekImage
focus?: number
note?: string
link?: string
rest?: boolean
}
interface WeekStripProps {
items: WeekDay[]
openDay: number
gap: number
radius: number
grow: number
openHeight: number
photoLag: number
meterFill: boolean
zoneMark: string
restWord: string
showArrows: boolean
showStat: boolean
statLabel: string
age: number
restOpens: string
restButton: string
fontFamily: string
ink: string
restFill: string
accent: string
zoneColors: string[]
buttonLabel: string
todayLabel: string
tomorrowLabel: string
zoneWord: string
withWord: string
unit: string
label: string
names: string[]
style?: React.CSSProperties
}
// The day that was open, kept for the visit: coming back to the page opens on it, without the entrance.
const KEY = "__lyrWeekStrip"
function memory(): { open?: number } | null {
if (typeof window === "undefined") return null
const w = window as any
if (!w[KEY]) w[KEY] = {}
return w[KEY]
}
const RANGES = [
[0.5, 0.6],
[0.6, 0.7],
[0.7, 0.8],
[0.8, 0.9],
[0.9, 1],
]
const clampZone = (z: any) => Math.min(5, Math.max(1, Math.round(Number(z) || 1)))
const bpmFor = (zone: number, age: number) => {
const max = 220 - age
const [lo, hi] = RANGES[clampZone(zone) - 1]
return Math.round((max * (lo + hi)) / 2)
}
const ratioFromUrl = (src?: string) => {
const m = /[?&]width=(\d+)&height=(\d+)/.exec(src || "")
return m ? Number(m[1]) / Number(m[2]) : 0
}
const reduced = () => typeof window !== "undefined" && !!window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches
// The sample week. Seven days, Monday first; a day without a photo shows a tinted slat.
const ITEMS: WeekDay[] = [
{ day: "Monday", title: "Strength circuit", time: "07:00", duration: "45 minutes", person: "Sam", zone: 3 },
{ day: "Tuesday", title: "Row and ride", time: "18:30", duration: "45 minutes", person: "Alex", zone: 2 },
{ day: "Wednesday", title: "Intervals", time: "18:30", duration: "45 minutes", person: "Sam", zone: 4, note: "4 places left" },
{ day: "Thursday", title: "Mobility and breath", time: "12:15", duration: "40 minutes", person: "Robin", zone: 1 },
{ day: "Friday", title: "Bag and sled", time: "06:30", duration: "30 minutes", person: "Alex", zone: 5 },
{ day: "Saturday", title: "Long run club", time: "09:00", duration: "70 minutes", person: "Robin", zone: 2 },
{ day: "Sunday", title: "Rest day", time: "Closed", zone: 1, rest: true },
]
const NAMES = ["Recover", "Base", "Tempo", "Hard", "Max"]
// One spring-like curve (--e) for everything that travels with the slats; fades are short and
// staggered so the opening day is never blank. The order is always geometry, then words, then
// the meter. --lag is set from script and is 0px unless the photos may lag the page. data-back and
// --bk are set from script on a day that is chosen again while it is still closing.
const CSS = `
.lyr-ws{container-type:inline-size;position:relative;width:100%;height:100%;contain:layout;font-family:var(--lyr-ws-font);-webkit-font-smoothing:antialiased;color:#fff;
--gw:calc(var(--n1) * var(--lyr-ws-gap));--ow:calc((100cqw - var(--gw)) * var(--lyr-ws-share));--cw:calc((100cqw - var(--gw)) * (1 - var(--lyr-ws-share)) / var(--n1));
--d:.82s;--e:cubic-bezier(.28,.36,.15,1);--rise:cubic-bezier(.2,.7,.2,1);--e-rise:cubic-bezier(.28,.48,.04,1);--e-std:cubic-bezier(.2,0,0,1);--lag:0px;timeline-scope:--lyr-ws-x}
@supports (transition-timing-function:linear(0,1)){.lyr-ws{--e:linear(0,0.0137 2.4%,0.0746 6%,0.1815 10.4%,0.3157 15.4%,0.4562 20.8%,0.5871 26.6%,0.699 32.8%,0.7888 39.2%,0.857 46%,0.9066 53%,0.9413 60.3%,0.9648 67.8%,0.9802 75.6%,0.9901 83.5%,0.9962 91.7%,1)}}
.lyr-ws *{box-sizing:border-box}
.lyr-ws button{font:inherit}
.lyr-ws::before{content:"";position:absolute;inset:0;border-radius:var(--lyr-ws-r);background:rgba(13,13,14,.06);opacity:0;transition:opacity .4s ease;pointer-events:none}
.lyr-ws[data-boot]::before{opacity:1}
.lyr-ws[data-boot] .lyr-ws-row,.lyr-ws[data-boot] .lyr-ws-tabs{visibility:hidden}
.lyr-ws[data-boot] *{transition:none!important}
.lyr-ws:not([data-boot]) .lyr-ws-slat{animation:lyr-ws-in .6s var(--rise) backwards;animation-delay:calc(var(--b,0) * 60ms)}
.lyr-ws[data-quiet] .lyr-ws-slat{animation:none}
@keyframes lyr-ws-in{from{opacity:0;transform:translateY(14px)}}
.lyr-ws-tabs{display:none}
.lyr-ws-row{display:flex;gap:var(--lyr-ws-gap);width:100%;height:100%;scroll-timeline:--lyr-ws-x x}
.lyr-ws-slat{position:relative;flex:1 1 0;min-width:0;border-radius:var(--lyr-ws-r);overflow:hidden;background:color-mix(in srgb,var(--z) 16%,#19191b);isolation:isolate;-webkit-tap-highlight-color:transparent}
.lyr-ws-slat[data-rest="true"]{background:var(--lyr-ws-rest);color:var(--lyr-ws-ink)}
.lyr-ws-img{filter:grayscale(1) contrast(1.08);opacity:0;user-select:none;-webkit-user-drag:none}
.lyr-ws-slat[data-in="true"] .lyr-ws-img{opacity:1}
.lyr-ws-tint{position:absolute;background:var(--z);mix-blend-mode:color;-webkit-mask-image:linear-gradient(12deg,#000 0%,rgba(0,0,0,.5) 30%,transparent 64%);mask-image:linear-gradient(12deg,#000 0%,rgba(0,0,0,.5) 30%,transparent 64%)}
.lyr-ws-glow{position:absolute;background:var(--z);mix-blend-mode:screen;-webkit-mask-image:radial-gradient(70% 60% at 30% 112%,rgba(0,0,0,.5),transparent 70%);mask-image:radial-gradient(70% 60% at 30% 112%,rgba(0,0,0,.5),transparent 70%)}
.lyr-ws-shade{position:absolute;inset:0;background:linear-gradient(180deg,rgba(10,10,11,.46) 0%,rgba(10,10,11,.04) 30%,rgba(10,10,11,.1) 50%,rgba(10,10,11,.82) 100%)}
.lyr-ws-slat[data-rest="true"] .lyr-ws-tint,.lyr-ws-slat[data-rest="true"] .lyr-ws-glow{display:none}
.lyr-ws-slat[data-rest="true"][data-in="false"] .lyr-ws-shade,.lyr-ws-slat[data-rest="true"][data-in="false"] .lyr-ws-shade-c{display:none}
.lyr-ws-slat[data-rest="true"][data-in="true"]{color:#fff}
.lyr-ws-zw{display:block;font-size:12.5px;font-weight:400;letter-spacing:-.005em;line-height:1;opacity:.72;white-space:nowrap;text-align:center}
.lyr-ws-meter{display:flex;gap:3px;justify-content:center}
.lyr-ws-meter i{display:block;border-radius:2px;height:4px;width:clamp(6px,.7cqw,10px);background:rgba(255,255,255,.24)}
.lyr-ws-meter i[data-on="true"]{background:var(--z)}
.lyr-ws-slat[data-rest="true"][data-in="false"] .lyr-ws-meter i{background:rgba(13,13,14,.16)}
@keyframes lyr-ws-fill{from{background:rgba(255,255,255,.24)}}
@keyframes lyr-ws-fill-tab{from{background:rgba(13,13,14,.14)}}
.lyr-ws[data-fill] .lyr-ws-day .lyr-ws-meter i[data-on="true"]{animation:lyr-ws-fill .3s ease backwards;animation-delay:calc(var(--b,0) * 60ms + .3s + var(--n,0) * 60ms)}
.lyr-ws[data-fill] .lyr-ws-tab .lyr-ws-meter i[data-on="true"]{animation:lyr-ws-fill-tab .3s ease backwards;animation-delay:calc(var(--b,0) * 60ms + .3s + var(--n,0) * 60ms)}
.lyr-ws-chip{display:inline-flex;align-items:center;gap:9px;background:#fff;color:var(--lyr-ws-ink);border-radius:999px;padding:9px 16px 9px 12px;font-size:14.5px;letter-spacing:-.012em;white-space:nowrap;line-height:1.2}
.lyr-ws-dot{width:8px;height:8px;border-radius:50%;background:var(--z);box-shadow:0 0 0 4px color-mix(in srgb,var(--z) 26%,transparent);flex:0 0 auto}
.lyr-ws-chip span{opacity:.6;margin-left:-6px}
.lyr-ws-zone{display:flex;align-items:center;gap:10px;font-size:14.5px;letter-spacing:-.01em}
.lyr-ws-zone .lyr-ws-meter i{width:13px;height:4px;background:rgba(255,255,255,.26)}
.lyr-ws-zone .lyr-ws-meter i[data-on="true"]{background:var(--z)}
.lyr-ws-title{font-weight:500;letter-spacing:-.05em;line-height:1;margin:12px 0 0;text-wrap:balance}
.lyr-ws-meta{font-size:15.5px;opacity:.9;margin:11px 0 0;letter-spacing:-.01em}
.lyr-ws-stat{font-size:13.5px;letter-spacing:-.01em;opacity:.74;text-align:right;margin-bottom:8px}
.lyr-ws-bpm{font-weight:400;letter-spacing:-.06em;line-height:.9;white-space:nowrap}
.lyr-ws-bpm span{font-size:15px;letter-spacing:-.01em;opacity:.72;margin-left:5px}
.lyr-ws-btn{display:inline-flex;align-items:center;padding:14px 22px;border-radius:999px;background:var(--z);color:var(--lyr-ws-ink);font-size:15.5px;font-weight:500;letter-spacing:-.014em;text-decoration:none;white-space:nowrap;border:0;cursor:pointer;transition:background-color .25s var(--e-std),color .25s var(--e-std),transform .12s var(--e-std)}
.lyr-ws-btn:hover{transform:none}
.lyr-ws-btn:active{transform:scale(.97)}
.lyr-ws-btn:focus-visible,.lyr-ws-nav button:focus-visible,.lyr-ws-tab:focus-visible{outline:2px solid #fff;outline-offset:2px}
.lyr-ws-slat[data-rest="true"][data-in="false"] .lyr-ws-chip{background:var(--lyr-ws-ink);color:#fff}
.lyr-ws-slat[data-rest="true"] .lyr-ws-btn{background:var(--lyr-ws-ink);color:#fff}
.lyr-ws-slat[data-rest="true"][data-in="true"] .lyr-ws-btn{background:#fff;color:var(--lyr-ws-ink)}
@media (hover:hover){
.lyr-ws-btn:hover{background:#fff}
.lyr-ws-slat[data-rest="true"] .lyr-ws-btn:hover{background:var(--z);color:var(--lyr-ws-ink)}
}
@container (min-width:761px){
.lyr-ws-row{aspect-ratio:774/420}
.lyr-ws-slat{transition:flex-grow var(--d) var(--e),transform .18s ease}
.lyr-ws-slat[data-open="true"]{flex-grow:var(--lyr-ws-grow)}
.lyr-ws-slat:not([data-open="true"]):active{transform:scale(.985)}
.lyr-ws-slat:has(.lyr-ws-hit:focus-visible){outline:2px solid var(--lyr-ws-ink);outline-offset:3px}
.lyr-ws-hit{position:absolute;inset:0;z-index:1;border:0;padding:0;background:none;border-radius:inherit;cursor:pointer;outline:none;color:inherit}
.lyr-ws-slat[data-open="true"] .lyr-ws-hit{cursor:default}
.lyr-ws-img{position:absolute;left:calc(var(--f,.5) * 100%);top:calc(30% - var(--lag) * .7);height:max(calc(100% + var(--lag) + min(var(--lag),6px)),calc(var(--ow) * 1.03 / var(--ar,.667)));aspect-ratio:var(--ar,.667);max-width:none;object-fit:fill;transform-origin:0 0;transform:scale(var(--k,1)) translate(calc(var(--f,.5) * -100%),-30%);transition:opacity .5s ease,transform var(--d) var(--e),scale .5s var(--rise)}
.lyr-ws-slat[data-open="true"] .lyr-ws-img{transform:scale(1) translate(calc(var(--f,.5) * -100%),-30%)}
.lyr-ws-tint,.lyr-ws-glow{inset:0 auto 0 0;width:var(--ow);opacity:0;transition:opacity .28s ease}
.lyr-ws-slat[data-open="true"] .lyr-ws-tint,.lyr-ws-slat[data-open="true"] .lyr-ws-glow{opacity:1;transition:opacity .5s ease .08s}
.lyr-ws-shade-c{position:absolute;inset:0;background:linear-gradient(180deg,rgba(10,10,11,.62) 0%,rgba(10,10,11,.1) 26%,rgba(10,10,11,.5) 50%,rgba(10,10,11,.88) 100%);transition:opacity .3s ease .2s}
.lyr-ws-slat[data-open="true"] .lyr-ws-shade-c{opacity:0;transition:opacity .3s ease}
.lyr-ws-wash{position:absolute;inset:0;background:var(--z);mix-blend-mode:color;opacity:0;transition:opacity .25s ease;pointer-events:none}
.lyr-ws-closed{position:absolute;top:0;bottom:0;left:0;width:var(--cw);z-index:2;pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:18px 0;transition:opacity .26s ease .3s}
.lyr-ws-slat[data-open="true"] .lyr-ws-closed{opacity:0;transition:opacity .12s ease}
.lyr-ws-day{font-size:16px;font-weight:500;letter-spacing:-.02em;text-align:center;white-space:nowrap}
.lyr-ws-day .lyr-ws-meter{margin-top:9px}
.lyr-ws-day .lyr-ws-zw{margin-top:7px}
.lyr-ws-name{writing-mode:vertical-rl;transform:rotate(180deg);font-size:17px;font-weight:500;letter-spacing:-.02em;white-space:nowrap;flex:1 1 auto;text-align:left;padding:16px 0;min-height:0;overflow:hidden}
.lyr-ws-slat[data-rest="true"][data-in="false"] .lyr-ws-name{opacity:.42}
.lyr-ws-time{font-size:13.5px;opacity:.82;text-align:center;min-height:1em;white-space:nowrap}
.lyr-ws-more{position:absolute;left:calc(var(--cw) / 2);bottom:10px;z-index:2;width:34px;height:34px;margin-left:-17px;border-radius:50%;background:#fff;color:var(--lyr-ws-ink);display:grid;place-items:center;opacity:0;transform:scale(.8);transition:opacity .2s ease,transform .3s var(--rise);pointer-events:none}
.lyr-ws-slat[data-rest="true"][data-in="false"] .lyr-ws-more{background:var(--lyr-ws-ink);color:#fff}
.lyr-ws-slat[data-open="true"] .lyr-ws-more{display:none}
.lyr-ws-time{transition:opacity .2s ease}
.lyr-ws-open{position:absolute;inset:0;z-index:3;pointer-events:none}
.lyr-ws-open a,.lyr-ws-open button{pointer-events:auto}
.lyr-ws-top,.lyr-ws-text,.lyr-ws-side{opacity:0;transform:translateY(10px);transition:opacity .11s ease-in,transform 0s linear .12s}
.lyr-ws-slat[data-open="true"] .lyr-ws-top{opacity:1;transform:none;transition:opacity .36s ease-out .16s,transform .5s var(--rise) .16s}
.lyr-ws-slat[data-open="true"] .lyr-ws-text{opacity:1;transform:none;transition:opacity .36s ease-out .22s,transform .5s var(--rise) .22s}
.lyr-ws-slat[data-open="true"] .lyr-ws-side{opacity:1;transform:none;transition:opacity .36s ease-out .28s,transform .5s var(--rise) .28s}
.lyr-ws-slat[data-open="true"][data-back] .lyr-ws-top{transition-delay:var(--bk,0s)}
.lyr-ws-slat[data-open="true"][data-back] .lyr-ws-text{transition-delay:calc(var(--bk,0s) + .06s)}
.lyr-ws-slat[data-open="true"][data-back] .lyr-ws-side{transition-delay:calc(var(--bk,0s) + .12s)}
.lyr-ws-top{position:absolute;left:20px;right:20px;top:20px;display:flex;justify-content:space-between;align-items:center;gap:12px}
.lyr-ws-text{position:absolute;left:28px;bottom:26px;width:calc(var(--ow) - 262px)}
.lyr-ws-side{position:absolute;right:22px;bottom:24px;display:flex;flex-direction:column;align-items:flex-end}
.lyr-ws-title{font-size:clamp(28px,3cqw,42px)}
.lyr-ws-bpm{font-size:clamp(40px,4.4cqw,62px);margin-bottom:16px}
.lyr-ws-nav{display:flex;gap:6px;flex:0 0 auto}
.lyr-ws-nav button{width:40px;height:40px;border-radius:50%;border:1px solid rgba(255,255,255,.28);background:rgba(255,255,255,.14);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px);color:#fff;cursor:pointer;display:grid;place-items:center;padding:0;transition:background-color .2s ease,color .2s ease,transform .15s ease}
.lyr-ws-nav button:hover{background:#fff;color:var(--lyr-ws-ink)}
.lyr-ws-nav button:active{transform:scale(.94)}
.lyr-ws-nav button[disabled]{opacity:.35;pointer-events:none}
.lyr-ws-slat[data-rest="true"][data-in="false"] .lyr-ws-nav button{border-color:rgba(13,13,14,.16);background:rgba(13,13,14,.05);color:var(--lyr-ws-ink)}
.lyr-ws-act{display:none}
.lyr-ws[data-meterfill] .lyr-ws-slat[data-open="true"] .lyr-ws-zone .lyr-ws-meter i[data-on="true"]{animation:lyr-ws-fill .3s ease backwards;animation-delay:calc(.3s + var(--n,0) * 60ms)}
}
@media (hover:hover){
@container (min-width:761px){
.lyr-ws-slat:not([data-open="true"]):not([data-rest="true"]):hover .lyr-ws-wash{opacity:.55}
.lyr-ws-slat:not([data-open="true"]):hover .lyr-ws-more{opacity:1;transform:scale(1)}
.lyr-ws-slat:not([data-open="true"]):hover .lyr-ws-time{opacity:0}
}
}
@container (min-width:761px){
.lyr-ws-slat:not([data-open="true"]):not([data-rest="true"]):active .lyr-ws-wash{opacity:.75}
}
@keyframes lyr-ws-lag{to{translate:0 var(--lag)}}
@media (prefers-reduced-motion:no-preference) and (hover:hover) and (pointer:fine){
@supports (animation-timeline:scroll()){
@container (min-width:761px){
.lyr-ws[data-lag] .lyr-ws-img{animation:lyr-ws-lag linear both;animation-timeline:scroll(root block);animation-range:0px 100vh}
}
}
}
@container (min-width:761px) and (max-width:1020px){
.lyr-ws-shade{background:linear-gradient(180deg,rgba(10,10,11,.46) 0%,rgba(10,10,11,.04) 26%,rgba(10,10,11,.44) 56%,rgba(10,10,11,.86) 100%)}
.lyr-ws-chip span{display:none}
.lyr-ws-text{width:calc(var(--ow) - 50px);bottom:88px}
.lyr-ws-side{left:28px;right:auto;align-items:flex-start}
.lyr-ws-stat,.lyr-ws-bpm{display:none}
.lyr-ws-nav button{width:36px;height:36px}
.lyr-ws-name{font-size:15px}
.lyr-ws-day{font-size:14.5px}
}
@container (max-width:760px){
.lyr-ws-shade{background:linear-gradient(180deg,rgba(10,10,11,.46) 0%,rgba(10,10,11,.04) 26%,rgba(10,10,11,.44) 56%,rgba(10,10,11,.86) 100%)}
.lyr-ws-tabs{display:grid;position:relative;grid-template-columns:repeat(var(--n),1fr);gap:4px;height:56px;margin-bottom:8px}
.lyr-ws-pill{position:absolute;left:0;top:0;height:100%;width:calc((100% - (var(--n) - 1) * 4px) / var(--n));border-radius:18px;background:#fff;pointer-events:none}
.lyr-ws-tab{appearance:none;position:relative;border:0;background:none;border-radius:18px;color:rgba(13,13,14,.62);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;padding:0;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:color .25s ease,transform .15s ease}
.lyr-ws-tab:active{transform:scale(.96)}
.lyr-ws-tab[aria-selected="true"]{background:none;color:var(--lyr-ws-ink)}
.lyr-ws-tab b{font-size:14.5px;font-weight:500;letter-spacing:-.02em;line-height:1}
.lyr-ws-tab .lyr-ws-meter{gap:2px}
.lyr-ws-tab .lyr-ws-zw{font-size:12px;opacity:1}
.lyr-ws-tab .lyr-ws-meter i{width:5px;height:3px;background:rgba(13,13,14,.14)}
.lyr-ws-tab .lyr-ws-meter i[data-on="true"]{background:var(--z)}
.lyr-ws-tab:focus-visible{outline-color:var(--lyr-ws-ink)}
.lyr-ws-row{height:auto;gap:8px;overflow-x:auto;overflow-y:hidden;scroll-snap-type:x mandatory;scrollbar-width:none;overscroll-behavior-x:contain;-webkit-overflow-scrolling:touch}
.lyr-ws-row::-webkit-scrollbar{display:none}
.lyr-ws-row::after{content:"";flex:0 0 18px}
.lyr-ws-slat{flex:0 0 calc(100% - 26px);height:var(--lyr-ws-openh);scroll-snap-align:start;scroll-snap-stop:always}
.lyr-ws-slat:last-child{scroll-snap-align:start}
.lyr-ws-hit,.lyr-ws-closed,.lyr-ws-nav,.lyr-ws-shade-c,.lyr-ws-wash,.lyr-ws-more,.lyr-ws-side{display:none}
.lyr-ws-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:calc(var(--f,.5) * 100%) 30%;transition:opacity .5s ease}
.lyr-ws-tint,.lyr-ws-glow{inset:0}
.lyr-ws-open{position:absolute;inset:0}
.lyr-ws-top{position:absolute;left:14px;top:14px;right:14px}
.lyr-ws-chip{font-size:13.5px}
.lyr-ws-text{position:absolute;left:20px;right:20px;bottom:20px}
.lyr-ws-title{font-size:32px}
.lyr-ws-meta{font-size:14.5px}
.lyr-ws-act{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px}
.lyr-ws-act .lyr-ws-stat{margin:0;line-height:1.25;opacity:.86}
.lyr-ws-act .lyr-ws-stat b{display:block;font-size:22px;font-weight:500;letter-spacing:-.04em}
@supports (animation-timeline:scroll()) and (timeline-scope:--x){.lyr-ws-pill{animation:lyr-ws-pill linear both;animation-timeline:--lyr-ws-x}}
@supports not ((animation-timeline:scroll()) and (timeline-scope:--x)){.lyr-ws-pill{transform:translateX(calc(var(--lyr-ws-sel,0) * (100% + 4px)));transition:transform .35s var(--e-rise)}}
}
@keyframes lyr-ws-pill{to{transform:translateX(calc((var(--n) - 1) * (100% + 4px)))}}
@media (prefers-reduced-motion:reduce){
.lyr-ws-slat,.lyr-ws-img{transition-duration:0s!important;animation:none!important}
.lyr-ws-top,.lyr-ws-text,.lyr-ws-side,.lyr-ws-closed,.lyr-ws-tint,.lyr-ws-glow,.lyr-ws-shade-c{transition:opacity .2s linear!important;transform:none!important}
.lyr-ws-row{scroll-behavior:auto}
.lyr-ws-meter i{animation:none!important}
.lyr-ws-img{scale:none!important}
.lyr-ws-pill{transition:none!important}
.lyr-ws-btn{transition:background-color .2s linear,color .2s linear!important}
}
`
function Meter({ zone, words, word, rest }: { zone: number; words?: boolean; word?: string; rest?: string }) {
// Words: the zone written out. A rest day keeps the line, empty, so nothing shifts.
if (words) {
return (
<span className="lyr-ws-zw" aria-hidden="true">
{zone ? [String(word || "").trim(), zone].filter(Boolean).join(" ") : rest || "\u00a0"}
</span>
)
}
return (
<span className="lyr-ws-meter" aria-hidden="true">
{[1, 2, 3, 4, 5].map((n) => (
<i key={n} data-on={n <= zone ? "true" : "false"} style={{ ["--n" as any]: n - 1 }} />
))}
</span>
)
}
const Chevron = ({ dir }: { dir: number }) => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" style={{ transform: dir < 0 ? "scaleX(-1)" : undefined }}>
<path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const Plus = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M7 2v10M2 7h10" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
</svg>
)
/**
* @framerSupportedLayoutWidth fixed
* @framerSupportedLayoutHeight any
* @framerIntrinsicWidth 1164
* @framerIntrinsicHeight 470
*/
export default function WeekStrip(props: WeekStripProps) {
const {
items = ITEMS,
openDay = 3,
gap = 10,
radius = 26,
grow = 5.2,
openHeight = 430,
photoLag = 24,
meterFill = true,
zoneMark = "Words",
restWord = "Rest",
showArrows = true,
showStat = true,
statLabel = "Target heart rate",
age = 34,
restOpens = "Next class",
restButton = "See {day}'s class",
fontFamily = "Inter",
ink = "#0D0D0E",
restFill = "#FFFFFF",
accent = "#FF4A33",
zoneColors = [],
buttonLabel = "Book this class",
todayLabel = "Today",
tomorrowLabel = "Tomorrow",
zoneWord = "Zone",
withWord = "with",
unit = "bpm",
label = "This week's classes",
names = NAMES,
style,
} = props
const words = !String(zoneMark).toLowerCase().startsWith("dash")
const isStatic = useIsStaticRenderer()
const onCanvas = RenderTarget.current() === RenderTarget.canvas
const still = isStatic || onCanvas
const lag = Math.max(0, Number(photoLag) || 0)
const uid = React.useId()
const [mounted, setMounted] = React.useState(false)
const [open, setOpen] = React.useState<number>(isStatic ? Math.max(0, Math.round(openDay || 1) - 1) : -1)
const [booted, setBooted] = React.useState(isStatic)
const [bootIdx, setBootIdx] = React.useState(-1)
const [quiet, setQuiet] = React.useState(isStatic)
const [fill, setFill] = React.useState(false)
const [armed, setArmed] = React.useState(false)
const [calm, setCalm] = React.useState(false)
const [compact, setCompact] = React.useState(false)
const [near, setNear] = React.useState(-1)
const [loaded, setLoaded] = React.useState<Record<number, boolean>>({})
const [natural, setNatural] = React.useState<Record<number, number>>({})
const rootRef = React.useRef<HTMLDivElement>(null)
const rowRef = React.useRef<HTMLDivElement>(null)
const hits = React.useRef<(HTMLButtonElement | null)[]>([])
const tabEls = React.useRef<(HTMLButtonElement | null)[]>([])
const slatEls = React.useRef<(HTMLDivElement | null)[]>([])
const imgEls = React.useRef<(HTMLImageElement | null)[]>([])
const settle = React.useRef<any>(0)
const nearRef = React.useRef(-1)
const aim = React.useRef(-1)
const cool = React.useRef<any[]>([])
const closedAt = React.useRef<number[]>([])
const warmed = React.useRef<Set<number>>(new Set())
const landRef = React.useRef<() => void>(() => {})
React.useEffect(() => {
setMounted(true)
}, [])
// The days in order, Monday first, at most seven.
const days: any[] = (Array.isArray(items) && items.length ? items : ITEMS).slice(0, 7).map((it, i) => ({
order: i + 1,
day: it.day || "",
title: it.title || "",
time: it.time || "",
duration: it.duration || "",
coach: it.person || "",
zone: Math.round(Number(it.zone) || 1),
src: it.image && it.image.src,
srcSet: it.image && it.image.srcSet,
alt: it.image && it.image.alt,
note: it.note || "",
link: it.link,
rest: !!it.rest,
focus: it.focus,
}))
const n = Math.max(2, days.length)
const todayOrder = mounted ? ((new Date().getDay() + 6) % 7) + 1 : -1
const todayIndex = days.findIndex((d) => d.order === todayOrder)
const nextClass = (from: number) => {
for (let step = 1; step <= n; step++) {
const j = (from + step) % n
if (!days[j].rest) return j
}
return from
}
// The day the strip opens on: the remembered day, else today, else the next class after a rest day.
const firstDay = () => {
const s = memory()
let first = onCanvas ? Math.min(n - 1, Math.max(0, Math.round(openDay || 1) - 1)) : todayIndex >= 0 ? todayIndex : 0
if (!onCanvas && s && typeof s.open === "number" && s.open < n) first = s.open
else if (!onCanvas && days[first] && days[first].rest && !String(restOpens).toLowerCase().startsWith("rest")) first = nextClass(first)
return first
}
// First open day, once the data is in. No layout animation: the strip is revealed after it.
// Quiet: a return visit inside one session, or the hero is already above the viewport.
React.useEffect(() => {
if (!mounted || open >= 0) return
const s = memory()
const root = rootRef.current
const first = firstDay()
const back = !!s && typeof s.open === "number"
const above = !!root && root.getBoundingClientRect().bottom < 0
nearRef.current = first
setBootIdx(first)
setQuiet(onCanvas || back || above)
setOpen(first)
setNear(first)
}, [mounted, open, n])
React.useEffect(() => {
if (booted || open < 0) return
let a = 0
let b = 0
a = requestAnimationFrame(() => {
const root = rootRef.current
const row = rowRef.current
const el = slatEls.current[open]
if (row && el && row.scrollWidth > row.clientWidth + 4) row.scrollTo({ left: el.offsetLeft - row.offsetLeft, behavior: "auto" })
if (root) root.style.setProperty("--lyr-ws-sel", String(open))
b = requestAnimationFrame(() => {
setBooted(true)
if (!quiet) {
setArmed(true)
if (meterFill && !reduced()) setFill(true)
}
})
})
return () => {
cancelAnimationFrame(a)
cancelAnimationFrame(b)
}
}, [open, booted])
// The boot meter fill lasts 1.6s, then the attribute goes so nothing can replay it.
React.useEffect(() => {
if (!fill) return
const t = setTimeout(() => setFill(false), 1600)
return () => clearTimeout(t)
}, [fill])
// Remember the open day for the visit.
React.useEffect(() => {
if (!mounted || onCanvas || open < 0) return
const s = memory()
if (s) s.open = open
}, [mounted, open])
// Photo ratios, the scale at which each photo just covers a closed slat, and the lag the photos may take.
const ratios = days.map((d, i) => ratioFromUrl(d.src) || natural[i] || 0)
const ratioKey = ratios.map((r) => r.toFixed(3)).join(",")
const share = grow / (n - 1 + grow)
React.useEffect(() => {
const root = rootRef.current
const row = rowRef.current
if (!root || !row) return
const mqCalm = window.matchMedia ? window.matchMedia("(prefers-reduced-motion: reduce)") : null
const mqFine = window.matchMedia ? window.matchMedia("(hover: hover) and (pointer: fine)") : null
// window.CSS, not CSS: in this file CSS is the stylesheet string.
const canLag = !still && lag > 0 && !!window.CSS && !!window.CSS.supports && window.CSS.supports("animation-timeline: scroll()")
const measure = () => {
const W = root.clientWidth
const H = row.clientHeight
const avail = W - (n - 1) * gap
const ow = avail * share
const cw = (avail * (1 - share)) / (n - 1)
const isCalm = !!mqCalm && mqCalm.matches
const lagOn = canLag && !isCalm && W > 760 && !!mqFine && mqFine.matches
const L = lagOn ? lag : 0
root.style.setProperty("--lag", L + "px")
// The scroll timeline is attached only while there is a lag to show, so "off" costs no layers.
if (L > 0) root.setAttribute("data-lag", "true")
else root.removeAttribute("data-lag")
slatEls.current.forEach((el, i) => {
const ar = ratios[i]
if (!el || !ar) return
const sh = Math.max(H + L + Math.min(L, 6), (ow * 1.03) / ar)
const sw = sh * ar
el.style.setProperty("--k", Math.min(1, 1.02 * Math.max(cw / sw, (H + L) / sh)).toFixed(4))
})
setCompact(W <= 760)
setCalm(isCalm)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(root)
const queries = [mqCalm, mqFine]
queries.forEach((mq) => {
if (mq && mq.addEventListener) mq.addEventListener("change", measure)
})
return () => {
ro.disconnect()
queries.forEach((mq) => {
if (mq && mq.removeEventListener) mq.removeEventListener("change", measure)
})
}
}, [ratioKey, n, gap, share, mounted, lag, still])
// A photo stage is promoted only while it moves.
const chill = (i: number) => {
clearTimeout(cool.current[i])
const img = imgEls.current[i]
if (img) img.style.willChange = ""
}
const heat = (i: number) => {
const img = imgEls.current[i]
if (!img) return
img.style.willChange = "transform"
clearTimeout(cool.current[i])
cool.current[i] = setTimeout(() => chill(i), 1000)
}
// Decode a closed day's photo as soon as the pointer or the keyboard shows intent. Opening never waits for it.
const warm = (i: number) => {
if (compact || i === open || warmed.current.has(i)) return
const img = imgEls.current[i]
if (!img || !img.decode) return
warmed.current.add(i)
img.decode().catch(() => {})
}
const mark = (i: number) => {
if (i === nearRef.current) return
nearRef.current = i
setNear(i)
const root = rootRef.current
if (root) root.style.setProperty("--lyr-ws-sel", String(i))
}
const select = (i: number, via: string) => {
const j = Math.min(n - 1, Math.max(0, i))
if (via === "key" || via === "nav")
requestAnimationFrame(() => {
const el = compact ? tabEls.current[j] : hits.current[j]
if (el) el.focus({ preventScroll: true })
})
if (compact && via !== "swipe") {
const row = rowRef.current
const el = slatEls.current[j]
if (row && el) {
const left = el.offsetLeft - row.offsetLeft
// Until the pager arrives, the tab that was chosen stays the marked one.
aim.current = Math.abs(left - row.scrollLeft) > 1 ? j : -1
row.scrollTo({ left, behavior: reduced() ? "auto" : "smooth" })
mark(j)
}
}
if (j === open) return
if (!compact && !reduced()) {
heat(open)
heat(j)
// A day chosen again before its closed label is back (0.56s) has nothing to hold it while the words
// wait, so its words return at once, or 60ms later when the label has begun to return. Every other
// choice keeps the full order.
const now = performance.now()
const was = closedAt.current[j]
const since = typeof was === "number" ? now - was : Infinity
slatEls.current.forEach((el, k) => {
if (!el) return
if (k === j && since < 560) {
el.style.setProperty("--bk", Math.round(Math.max(0, Math.min(60, since - 300))) + "ms")
el.setAttribute("data-back", "true")
} else el.removeAttribute("data-back")
})
closedAt.current[open] = now
}
if (!armed) setArmed(true)
setOpen(j)
mark(j)
}
const keys = (i: number) => (e: React.KeyboardEvent) => {
let j = -1
if (e.key === "ArrowRight" || e.key === "ArrowDown") j = i + 1
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") j = i - 1
else if (e.key === "Home") j = 0
else if (e.key === "End") j = n - 1
if (j < 0 || j > n - 1) return
e.preventDefault()
select(j, "key")
}
const nearest = () => {
const row = rowRef.current
let best = -1
if (!row) return best
let dist = Infinity
slatEls.current.forEach((el, i) => {
if (!el) return
const dx = Math.abs(el.offsetLeft - row.offsetLeft - row.scrollLeft)
if (dx < dist) {
dist = dx
best = i
}
})
return best
}
// The pager has come to rest: commit the day.
const land = () => {
clearTimeout(settle.current)
if (!compact || open < 0) return
aim.current = -1
const best = nearest()
if (best < 0) return
mark(best)
select(best, "swipe")
}
const onRowScroll = () => {
if (!compact || open < 0) return
const best = nearest()
if (best < 0) return
if (aim.current >= 0) {
if (best === aim.current) aim.current = -1
} else mark(best)
clearTimeout(settle.current)
settle.current = setTimeout(() => landRef.current(), 140)
}
React.useEffect(() => {
landRef.current = land
})
React.useEffect(() => {
const row = rowRef.current
const done = () => landRef.current()
if (row) row.addEventListener("scrollend", done)
return () => {
if (row) row.removeEventListener("scrollend", done)
clearTimeout(settle.current)
cool.current.forEach((t) => clearTimeout(t))
}
}, [])
// Crossing into the phone layout after boot: bring the open card under the tabs.
React.useEffect(() => {
if (!compact || open < 0) return
const row = rowRef.current
const el = slatEls.current[open]
if (row && el) row.scrollTo({ left: el.offsetLeft - row.offsetLeft, behavior: "auto" })
}, [compact])
const monday = React.useMemo(() => {
if (!mounted) return null
const d = new Date()
d.setHours(12, 0, 0, 0)
d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
return d
}, [mounted])
const dateOf = (order: number) => {
if (!monday) return null
const d = new Date(monday)
d.setDate(monday.getDate() + (order - 1) + (todayOrder > 0 && order < todayOrder ? 7 : 0))
return d
}
const fmt = (d: Date, long: boolean) => {
if (!mounted) return ""
let lang: string | undefined = undefined
try {
lang = document.documentElement.lang || undefined
} catch (e) {}
try {
return d.toLocaleDateString(lang, long ? { weekday: "long", day: "numeric", month: "long" } : { weekday: "short", day: "numeric", month: "short" })
} catch (e) {
return d.toDateString()
}
}
const zoneNames: string[] = Array.isArray(names) && names.length ? names : NAMES
// Each zone's colour: its own from Zone colours, or the accent.
const zoneColor = (z: number) => (Array.isArray(zoneColors) && zoneColors[z - 1]) || accent
const vars: any = {
...style,
"--lyr-ws-gap": `${gap}px`,
"--lyr-ws-r": `${radius}px`,
"--lyr-ws-grow": grow,
"--lyr-ws-share": share,
"--n": n,
"--n1": n - 1,
"--lyr-ws-openh": `${openHeight}px`,
"--lyr-ws-font": `"${fontFamily}", "Inter", system-ui, sans-serif`,
"--lyr-ws-ink": ink,
"--lyr-ws-rest": restFill,
}
const shown: any[] = days
// The first photo to fetch is the day the strip opens on, known one render before it opens.
const hot = bootIdx >= 0 ? bootIdx : isStatic ? open : mounted ? firstDay() : -1
const beat = (i: number) => Math.min(Math.abs(i - bootIdx), 4)
return (
<div
ref={rootRef}
className="lyr-ws"
role="group"
aria-label={label}
data-boot={booted ? undefined : "true"}
data-quiet={quiet ? "true" : undefined}
data-fill={fill ? "true" : undefined}
data-meterfill={meterFill && armed && !calm ? "true" : undefined}
style={vars}
>
<style dangerouslySetInnerHTML={{ __html: CSS }} />
<div className="lyr-ws-tabs" role="tablist" aria-label={label}>
<span className="lyr-ws-pill" aria-hidden="true" />
{days.map((d, i) => (
<button
key={i}
type="button"
className="lyr-ws-tab"
role="tab"
id={`${uid}-t${i}`}
ref={(el) => {
tabEls.current[i] = el
}}
aria-selected={(near >= 0 ? near : open) === i}
aria-controls={`${uid}-f${i}`}
tabIndex={open === i ? 0 : -1}
style={{ ["--z" as any]: zoneColor(clampZone(d.zone)), ["--b" as any]: beat(i) }}
onClick={() => select(i, "tab")}
onKeyDown={keys(i)}
>
<b>{mounted && d.order === todayOrder ? todayLabel : String(d.day || "").slice(0, 3)}</b>
<Meter zone={d.rest ? 0 : clampZone(d.zone)} words={words} word={zoneWord} rest={d.rest ? restWord : undefined} />
</button>
))}
</div>
<div
className="lyr-ws-row"
ref={rowRef}
onScroll={onRowScroll}
onPointerDown={() => {
aim.current = -1
}}
>
{shown.map((d, i) => {
const isOpen = open === i
const z = d ? clampZone(d.zone) : 1
const isToday = !!d && d.order === todayOrder
const isTomorrow = !!d && todayOrder > 0 && d.order === (todayOrder % 7) + 1
const short = d ? String(d.day || "").slice(0, 3) : ""
const hasPhoto = !!(d && d.src)
const date = d ? dateOf(d.order) : null
const prefix = isToday ? todayLabel : isTomorrow ? tomorrowLabel : ""
const faceShown = compact || isOpen
const hitId = `${uid}-h${i}`
const tabId = `${uid}-t${i}`
const faceId = `${uid}-f${i}`
const longName = d ? [date ? fmt(date, true) : d.day, d.title, d.time, d.rest ? "" : `${zoneWord} ${z}`].filter(Boolean).join(", ") : ""
const stat = (
<>
<div className="lyr-ws-stat">{statLabel}</div>
<div className="lyr-ws-bpm">
{d ? bpmFor(z, age) : ""}
<span>{unit}</span>
</div>
</>
)
const action =
d && d.rest ? (
<button type="button" className="lyr-ws-btn" tabIndex={faceShown ? 0 : -1} onClick={() => select(nextClass(i), "nav")}>
{String(restButton).replace("{day}", days[nextClass(i)] ? days[nextClass(i)].day : "")}
</button>
) : (
<a className="lyr-ws-btn" href={(d && d.link) || "#"} tabIndex={faceShown ? 0 : -1}>
{buttonLabel}
</a>
)
return (
<div
key={i}
ref={(el) => {
slatEls.current[i] = el
}}
className="lyr-ws-slat"
data-open={isOpen ? "true" : "false"}
data-rest={d && d.rest ? "true" : "false"}
data-in={hasPhoto && loaded[i] ? "true" : "false"}
style={{
["--z" as any]: zoneColor(z),
["--i" as any]: i,
["--b" as any]: beat(i),
["--f" as any]: d && d.focus != null ? Math.min(1, Math.max(0, d.focus / 100)) : 0.5,
...(ratios[i] ? { ["--ar" as any]: ratios[i].toFixed(4) } : {}),
}}
onPointerEnter={() => warm(i)}
onPointerDown={() => warm(i)}
onTransitionEnd={(e: React.TransitionEvent<HTMLDivElement>) => {
if (e.target === e.currentTarget && e.propertyName === "flex-grow") chill(i)
}}
>
{hasPhoto ? (
<img
className="lyr-ws-img"
src={d.src}
srcSet={d.srcSet}
sizes="(max-width: 809px) 92vw, 46vw"
alt={d.alt || ""}
decoding="async"
draggable={false}
{...({ fetchpriority: i === hot ? "high" : "low" } as any)}
ref={(img) => {
imgEls.current[i] = img
if (img && img.complete && img.naturalWidth && !loaded[i]) {
if (!ratioFromUrl(d.src) && !natural[i]) setNatural((r) => ({ ...r, [i]: img.naturalWidth / img.naturalHeight }))
setLoaded((l) => (l[i] ? l : { ...l, [i]: true }))
}
}}
onLoad={(e) => {
const img = e.currentTarget
if (!ratioFromUrl(d.src)) setNatural((r) => ({ ...r, [i]: img.naturalWidth / img.naturalHeight }))
const done = () => setLoaded((l) => (l[i] ? l : { ...l, [i]: true }))
if (img.decode) img.decode().catch(() => {}).then(done)
else done()
}}
/>
) : null}
<div className="lyr-ws-tint" />
<div className="lyr-ws-glow" />
<div className="lyr-ws-shade" />
<div className="lyr-ws-shade-c" />
<div className="lyr-ws-wash" />
<button
type="button"
className="lyr-ws-hit"
id={hitId}
ref={(el) => {
hits.current[i] = el
}}
aria-expanded={isOpen}
aria-controls={faceId}
aria-current={isToday ? "date" : undefined}
aria-label={longName}
tabIndex={isOpen ? 0 : -1}
onClick={() => select(i, "pointer")}
onKeyDown={keys(i)}
onFocus={() => warm(i)}
/>
<div className="lyr-ws-closed" aria-hidden="true">
<div className="lyr-ws-day">
{isToday ? todayLabel : short}
<Meter zone={d && !d.rest ? z : 0} words={words} word={zoneWord} />
</div>
<div className="lyr-ws-name">{d ? d.title : ""}</div>
<div className="lyr-ws-time">{d ? d.time : ""}</div>
</div>
<div className="lyr-ws-more" aria-hidden="true">
<Plus />
</div>
<div className="lyr-ws-open" id={faceId} role={compact ? "tabpanel" : "region"} aria-labelledby={compact ? tabId : hitId} {...(faceShown ? {} : ({ inert: "" } as any))}>
{d ? (
<>
<div className="lyr-ws-top">
<div className="lyr-ws-chip">
<i className="lyr-ws-dot" />
{[prefix, date ? fmt(date, false) : d.day].filter(Boolean).join(" · ")}
{d.note ? <span>{`· ${d.note}`}</span> : null}
</div>
{showArrows ? (
<div className="lyr-ws-nav">
<button type="button" aria-label="Previous day" disabled={i === 0} tabIndex={isOpen ? 0 : -1} onClick={() => select(i - 1, "nav")}>
<Chevron dir={-1} />
</button>
<button type="button" aria-label="Next day" disabled={i === n - 1} tabIndex={isOpen ? 0 : -1} onClick={() => select(i + 1, "nav")}>
<Chevron dir={1} />
</button>
</div>
) : null}
</div>
<div className="lyr-ws-text">
{!d.rest ? (
<div className="lyr-ws-zone">
{words ? null : <Meter zone={z} />}
{zoneWord} {z} · {zoneNames[z - 1] || ""}
</div>
) : null}
<h3 className="lyr-ws-title">{d.title}</h3>
<p className="lyr-ws-meta">{[d.time, d.duration, d.coach ? `${withWord} ${d.coach}` : ""].filter(Boolean).join(" · ")}</p>
<div className="lyr-ws-act">
{action}
{!d.rest && showStat ? (
<div className="lyr-ws-stat">
{statLabel}
<b>
{bpmFor(z, age)} {unit}
</b>
</div>
) : null}
</div>
</div>
<div className="lyr-ws-side">
{!d.rest && showStat ? stat : null}
{action}
</div>
</>
) : null}
</div>
</div>
)
})}
</div>
</div>
)
}
addPropertyControls(WeekStrip, {
items: {
type: ControlType.Array,
title: "Days",
maxCount: 7,
description: "Seven days, Monday first. Today is found by its place in the list.",
control: {
type: ControlType.Object,
controls: {
day: { type: ControlType.String, title: "Day", defaultValue: "Monday" },
title: { type: ControlType.String, title: "Title", defaultValue: "" },
time: { type: ControlType.String, title: "Time", defaultValue: "" },
duration: { type: ControlType.String, title: "Length", defaultValue: "" },
person: { type: ControlType.String, title: "With", defaultValue: "", description: "Who leads it. Written after the With word." },
zone: { type: ControlType.Number, title: "Zone", defaultValue: 3, min: 1, max: 5, step: 1, displayStepper: true },
image: { type: ControlType.ResponsiveImage, title: "Photo" },
focus: { type: ControlType.Number, title: "Photo focus", defaultValue: 50, min: 0, max: 100, step: 1, unit: "%" },
note: { type: ControlType.String, title: "Note", defaultValue: "", description: "A short line after the date, for example 4 places left." },
link: { type: ControlType.Link, title: "Button link" },
rest: { type: ControlType.Boolean, title: "Rest day", defaultValue: false },
},
},
defaultValue: ITEMS,
},
openDay: {
type: ControlType.Number,
title: "Canvas day",
defaultValue: 3,
min: 1,
max: 7,
step: 1,
displayStepper: true,
description: "The day shown open on the canvas. The live site always opens today.",
},
grow: { type: ControlType.Number, title: "Open size", defaultValue: 5.2, min: 2, max: 9, step: 0.1 },
gap: { type: ControlType.Number, title: "Gap", defaultValue: 10, min: 0, max: 32, step: 1, unit: "px" },
radius: { type: ControlType.Number, title: "Radius", defaultValue: 26, min: 0, max: 60, step: 1, unit: "px" },
openHeight: { type: ControlType.Number, title: "Phone open", defaultValue: 430, min: 240, max: 720, step: 10, unit: "px", description: "Height of the card on phones." },
photoLag: {
type: ControlType.Number,
title: "Photo lag",
defaultValue: 24,
min: 0,
max: 40,
step: 2,
unit: "px",
description: "How far the photos sit behind the page while it scrolls. 0 turns it off.",
},
meterFill: {
type: ControlType.Boolean,
title: "Meter fill",
defaultValue: true,
description: "Meters fill bar by bar when the week first appears and when a day opens.",
},
zoneMark: {
type: ControlType.Enum,
title: "Zone mark",
options: ["Words", "Dashes"],
optionTitles: ["Words", "Dashes"],
defaultValue: "Words",
description: "How each day shows its zone: written out, or as five small dashes.",
},
restWord: { type: ControlType.String, title: "Rest word", defaultValue: "Rest", description: "Shown under a rest day on the phone tabs." },
showArrows: { type: ControlType.Boolean, title: "Arrows", defaultValue: true },
showStat: { type: ControlType.Boolean, title: "Heart rate", defaultValue: true, description: "A target heart rate for the open day, worked out from its zone and the age below." },
statLabel: { type: ControlType.String, title: "Stat label", defaultValue: "Target heart rate", hidden: (p: Partial<WeekStripProps>) => p.showStat === false },
age: { type: ControlType.Number, title: "Age", defaultValue: 34, min: 14, max: 90, step: 1, description: "The heart rate uses 220 minus this age as the maximum.", hidden: (p: Partial<WeekStripProps>) => p.showStat === false },
restOpens: {
type: ControlType.Enum,
title: "Rest day opens",
options: ["Next class", "Rest day"],
optionTitles: ["Next class", "Rest day"],
defaultValue: "Next class",
description: "What the strip shows when today is a rest day.",
},
restButton: { type: ControlType.String, title: "Rest button", defaultValue: "See {day}'s class" },
fontFamily: { type: ControlType.String, title: "Font family", defaultValue: "Inter" },
ink: { type: ControlType.Color, title: "Ink", defaultValue: "#0D0D0E" },
restFill: { type: ControlType.Color, title: "Rest day", defaultValue: "#FFFFFF" },
accent: { type: ControlType.Color, title: "Accent", defaultValue: "#FF4A33", description: "The dot, the meters and the button." },
zoneColors: {
type: ControlType.Array,
title: "Zone colours",
control: { type: ControlType.Color },
maxCount: 5,
defaultValue: [],
description: "Optional: one colour per zone, 1 to 5. Zones without one use the accent.",
},
buttonLabel: { type: ControlType.String, title: "Button", defaultValue: "Book this class" },
todayLabel: { type: ControlType.String, title: "Today word", defaultValue: "Today" },
tomorrowLabel: { type: ControlType.String, title: "Tomorrow word", defaultValue: "Tomorrow" },
zoneWord: { type: ControlType.String, title: "Zone word", defaultValue: "Zone" },
withWord: { type: ControlType.String, title: "With word", defaultValue: "with" },
unit: { type: ControlType.String, title: "Unit", defaultValue: "bpm" },
label: { type: ControlType.String, title: "Spoken name", defaultValue: "This week's classes", description: "Read out by screen readers." },
names: {
type: ControlType.Array,
title: "Zone names",
control: { type: ControlType.String },
defaultValue: ["Recover", "Base", "Tempo", "Hard", "Max"],
maxCount: 5,
},
})
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.