Layr Components

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
3
5.2
10 px
26 px
430 px
24 px
34
#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.