Picture Trail
A picture's trail through time on a pinned stage: a huge name crosses the view, its holders pass beside it in turn, and a second name follows and stays faded behind it. No dependencies.
No dependencies
Seen inBetween Rivers
Conqueror
Last ruler
First dynasty
Second dynasty
Third dynasty
Fourth dynasty
Fifth dynasty
Sixth dynasty
The siege, c. 1258 CEA museum somewhere. Placeholder photograph.
0px
Customize
Holders6
Picture#1
Install
Terminal
$
npx shadcn@latest add @mewo/picture-trailNot using the registry directory?
Point the CLI straight at the item URL:
npx shadcn@latest add https://www.mewo-ui.com/r/picture-trail.json
Props
| Prop | Type | Default | Description |
|---|---|---|---|
top | string | "Conqueror" | The huge name that crosses the view first, from the bottom to the top. |
bottom | string | "Last ruler" | The huge name that follows and stays, faded, behind the picture. |
holders | string[] | six dynasties | The holders, passing in turn beside the picture, left and right (six have room). |
image | { src, alt?, credit: { title, by, line, href? } } | a picsum photo | The picture (about 9 : 10), with its credit under it. |
className | string | — | Merged onto the root: the tall section the stage pins in (six view heights). |
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export interface PictureTrailCredit {
/** The picture's name, underlined (linked when href is set). */
title: string
/** Place and date, after the name. */
by: string
/** The small grey line: where it is kept, the photograph's credit and licence. */
line: string
/** Where the name links (it opens in a new tab). */
href?: string
}
export interface PictureTrailProps extends React.ComponentProps<"section"> {
/** The huge name that crosses the view first, from the bottom to the top. */
top?: string
/** The huge name that follows and stays, faded, behind the picture. */
bottom?: string
/** The holders, passing in turn beside the picture, left and right (six have room). */
holders?: string[]
/** The picture (about 9 : 10), with its credit under it. */
image?: { src: string; alt?: string; credit: PictureTrailCredit }
}
export type PictureTrailLayout = "desktop" | "tablet" | "phone"
/** One keyframe: [view heights after the stage pins, centre x (% of the view's width), centre y (% of its height),
* scale, opacity]. */
type Key = readonly number[]
type Track = { size: readonly [number, number]; keys: readonly Key[]; on?: readonly (readonly [number, number])[] }
type Part = "top" | "bottom" | "buyer0" | "buyer1" | "buyer2" | "buyer3" | "buyer4" | "buyer5" | "img" | "credit"
type Layout = { view: readonly [number, number] } & Record<Part, Track>
// The motion per screen shape, measured on a reference view: each part's layout size there and its keyframes.
// Positions are shares of the view, so they hold in any box; sizes are set on the box's width (the unit below), as
// on the reference view, so the parts land where they were measured. The credit hangs under the picture.
const TRACKS: Record<PictureTrailLayout, Layout> = {
desktop: {
view: [1440, 900],
top: { size: [1103,207], keys: [[0,49.97,114.94,1,0],[0.1,49.97,114.94,1,0],[0.15,49.97,114.94,1,1],[1,49.97,24.06,1,1],[1.05,49.97,17.06,1,1],[1.5,49.97,-29.5,1,1],[5,49.97,-29.61,1,1]] },
bottom: { size: [1371,207], keys: [[0,50.03,133.39,1,0],[0.1,50.03,133.39,1,0],[0.15,50.03,133.39,1,1],[3.65,50.03,133.39,1,1],[4.1,50.03,85.39,1,1],[4.15,50.03,79.94,1,0.89],[4.35,50.03,58.61,1,0.21],[5,50.03,-10.93,1,0.2]] },
buyer0: { size: [405,130], keys: [[0,19.06,107.22,1,0],[0.1,19.06,107.22,1,0],[0.15,19.06,107.22,1,1],[1,19.06,107.22,1,1],[1.05,19.06,105.56,1,1],[2.4,19.06,-38.89,1,1],[2.45,19.06,-42.78,1,1],[5,19.06,-42.78,1,1]] },
buyer1: { size: [405,130], keys: [[0,80.94,107.22,1,0],[0.1,80.94,107.22,1,0],[0.15,80.94,107.22,1,1],[1.5,80.94,107.22,1,1],[2.9,80.94,-42.33,1,1],[5,80.94,-42.78,1,1]] },
buyer2: { size: [405,65], keys: [[0,19.06,103.61,1,0],[0.1,19.06,103.61,1,0],[0.15,19.06,103.61,1,1],[1.95,19.06,103.61,1,1],[2,19.06,100.28,1,1],[3.35,19.06,-44.17,1,1],[3.4,19.06,-46.39,1,1],[5,19.06,-46.39,1,1]] },
buyer3: { size: [405,65], keys: [[0,80.94,103.61,1,0],[0.1,80.94,103.61,1,0],[0.15,80.94,103.61,1,1],[2.4,80.94,103.61,1,1],[2.45,80.94,102.17,1,1],[3.8,80.94,-42.28,1,1],[3.85,80.94,-46.39,1,1],[5,80.94,-46.39,1,1]] },
buyer4: { size: [405,65], keys: [[0,19.06,103.61,1,0],[0.1,19.06,103.61,1,0],[0.15,19.06,103.61,1,1],[2.9,19.06,103.61,1,1],[4.3,19.06,-45.83,1,1],[4.35,19.06,-46.39,1,1],[5,19.06,-46.39,1,1]] },
buyer5: { size: [405,130], keys: [[0,80.94,107.22,1,0],[0.1,80.94,107.22,1,0],[0.15,80.94,107.22,1,1],[3.35,80.94,107.22,1,1],[3.4,80.94,104.11,1,1],[4.75,80.94,-40.33,1,1],[4.8,80.94,-42.78,1,1],[5,80.94,-42.78,1,1]] },
img: { size: [1440,1595], keys: [[0,50,143.94,0.2542,0],[0.1,50,143.94,0.2542,0],[0.15,50,143.94,0.2542,1],[0.3,50,126.72,0.2542,1],[0.5,50,106.61,0.2542,1],[0.7,50,89.61,0.2542,1],[0.9,50,75.72,0.2542,1],[1.05,50,67.39,0.2542,1],[1.15,50,62.83,0.2542,1],[1.3,50,57.39,0.2542,1],[1.4,50,54.72,0.2542,1],[1.55,50,52.28,0.2542,1],[1.75,50,51.39,0.2542,1],[5,50,47.48,0.2542,1]] },
credit: { size: [178,48], keys: [[0,50,169.11,1,0],[0.1,50,169.11,1,0],[0.15,50,169.11,1,1],[0.3,50,151.89,1,1],[0.4,50,141.44,1,1],[0.55,50,127.22,1,1],[0.7,50,114.78,1,1],[0.8,50,107.44,1,1],[0.9,50,101,1,1],[1.05,50,92.56,1,1],[1.15,50,88,1,1],[1.3,50,82.56,1,1],[1.45,50,78.89,1,1],[1.6,50,77,1,1],[1.75,50,76.56,1,1],[5,50,72.64,1,1]] },
},
tablet: {
view: [768, 900],
top: { size: [358,67], keys: [[0,50,105.39,1,0],[0.1,50,105.39,1,0],[0.15,50,105.39,1,1],[0.25,50,89.28,1,1],[0.35,50,75.06,1,1],[0.4,50,68.72,1,1],[0.5,50,57.39,1,1],[0.6,50,47.72,1,1],[0.75,50,36.17,1,1],[0.85,50,30.17,1,1],[0.95,50,25.39,1,1],[1.05,50,21.61,1,1],[1.2,50,17.83,1,1],[1.3,50,16.17,1,1],[1.45,50,14.83,1,1],[1.7,50,14.28,1,1],[3.15,50,14.28,1,1],[3.3,50,13.39,1,1],[3.4,50,11.39,1,1],[3.5,50,7.61,1,1],[3.55,50,4.83,1,1],[3.6,50,1.5,1,1],[3.7,50,-7.39,1,1],[3.8,50,-19.72,1,1],[3.85,50,-27.39,1,1],[3.95,50,-44.61,1,1],[4,50,-52.17,1,1],[4.05,50,-58.72,1,1],[4.15,50,-69.17,1,1],[4.25,50,-76.5,1,1],[4.3,50,-79.17,1,1],[4.4,50,-82.83,1,1],[4.55,50,-85.28,1,1],[5,50,-85.72,1,1]] },
bottom: { size: [445,67], keys: [[0,50.07,136.83,1,0],[0.1,50.07,136.83,1,0],[0.15,50.07,136.83,1,1],[2.1,50.07,136.83,1,1],[2.15,50.07,131.06,1,1],[2.2,50.07,122.72,1,1],[2.25,50.07,115.39,1,1],[2.35,50.07,104.06,1,1],[2.4,50.07,99.61,1,1],[2.45,50.07,96.17,1,1],[2.5,50.07,93.28,1,1],[2.6,50.07,89.5,1,1],[2.7,50.07,87.61,1,1],[2.8,50.07,86.94,1,1],[3.5,50.07,86.83,1,1],[3.6,50.07,86.28,1,1],[3.75,50.07,83.5,1,1],[3.85,50.07,79.39,1,1],[3.95,50.07,73.06,1,1],[4.05,50.07,63.61,1,1],[4.1,50.07,57.72,1,1],[4.15,50.07,50.83,1,1],[4.3,50.07,25.83,1,1],[4.35,50.07,18.5,1,1],[4.45,50.07,6.83,1,1],[4.5,50.07,2.28,1,1],[4.6,50.07,-4.72,1,1],[4.7,50.07,-9.17,1,1],[4.8,50.07,-11.72,1,1],[5,50.07,-13.17,1,1]] },
buyer0: { size: [744,29], keys: [[0,50,135.39,1,0],[0.1,50,135.39,1,0],[0.15,50,135.39,1,1],[1.05,50,135.39,1,1],[1.1,50,128.5,1,1],[1.2,50,112.06,1,1],[1.3,50,97.61,1,1],[1.45,50,79.61,1,1],[1.6,50,65.5,1,1],[1.7,50,57.94,1,1],[1.85,50,49.17,1,1],[1.95,50,44.83,1,1],[2.05,50,41.5,1,1],[2.15,50,39.06,1,1],[2.3,50,36.72,1,1],[2.55,50,35.39,1,1],[3.25,50,35.17,1,1],[3.4,50,33.5,1,1],[3.5,50,30.61,1,1],[3.55,50,28.5,1,1],[3.65,50,22.39,1,1],[3.75,50,13.39,1,1],[3.85,50,0.94,1,1],[3.9,50,-6.61,1,1],[4,50,-23.94,1,1],[4.05,50,-31.39,1,1],[4.1,50,-37.94,1,1],[4.2,50,-48.28,1,1],[4.3,50,-55.5,1,1],[4.35,50,-58.17,1,1],[4.45,50,-61.83,1,1],[4.6,50,-64.28,1,1],[5,50,-64.51,1,1]] },
buyer1: { size: [744,29], keys: [[0,50,141.17,1,0],[0.1,50,141.17,1,0],[0.15,50,141.17,1,1],[1.1,50,141.17,1,1],[1.15,50,133.94,1,1],[1.2,50,125.5,1,1],[1.3,50,110.17,1,1],[1.4,50,96.83,1,1],[1.5,50,85.28,1,1],[1.6,50,75.5,1,1],[1.7,50,67.28,1,1],[1.8,50,60.5,1,1],[1.9,50,54.94,1,1],[2.05,50,48.83,1,1],[2.15,50,46.06,1,1],[2.3,50,43.17,1,1],[2.6,50,41.28,1,1],[3.3,50,41.06,1,1],[3.45,50,39.39,1,1],[3.55,50,36.5,1,1],[3.65,50,31.5,1,1],[3.7,50,28.06,1,1],[3.8,50,19.06,1,1],[3.9,50,6.61,1,1],[3.95,50,-1.06,1,1],[4.05,50,-18.39,1,1],[4.1,50,-25.83,1,1],[4.15,50,-32.28,1,1],[4.25,50,-42.61,1,1],[4.35,50,-49.83,1,1],[4.45,50,-54.5,1,1],[4.6,50,-57.94,1,1],[5,50,-58.83,1,1]] },
buyer2: { size: [744,29], keys: [[0,50,147.06,1,0],[0.1,50,147.06,1,0],[0.15,50,147.06,1,1],[1.15,50,147.06,1,1],[1.35,50,115.72,1,1],[1.45,50,102.5,1,1],[1.55,50,90.94,1,1],[1.65,50,81.17,1,1],[1.75,50,73.06,1,1],[1.85,50,66.28,1,1],[1.95,50,60.72,1,1],[2.1,50,54.72,1,1],[2.2,50,51.83,1,1],[2.35,50,49.06,1,1],[2.5,50,47.61,1,1],[2.7,50,47.06,1,1],[3.35,50,46.94,1,1],[3.5,50,45.17,1,1],[3.6,50,42.28,1,1],[3.7,50,37.28,1,1],[3.75,50,33.83,1,1],[3.85,50,24.72,1,1],[3.9,50,18.94,1,1],[3.95,50,12.17,1,1],[4,50,4.5,1,1],[4.1,50,-12.83,1,1],[4.15,50,-20.17,1,1],[4.25,50,-32.17,1,1],[4.35,50,-40.83,1,1],[4.45,50,-46.61,1,1],[4.6,50,-51.28,1,1],[4.75,50,-52.83,1,1],[5,50,-52.84,1,1]] },
buyer3: { size: [744,29], keys: [[0,50,152.94,1,0],[0.1,50,152.94,1,0],[0.15,50,152.94,1,1],[1.2,50,152.94,1,1],[1.35,50,128.72,1,1],[1.4,50,121.39,1,1],[1.5,50,108.06,1,1],[1.55,50,102.17,1,1],[1.65,50,91.61,1,1],[1.75,50,82.61,1,1],[1.85,50,75.17,1,1],[1.95,50,69.06,1,1],[2.05,50,64.28,1,1],[2.2,50,58.94,1,1],[2.35,50,55.61,1,1],[2.45,50,54.28,1,1],[2.65,50,53.06,1,1],[3.4,50,52.72,1,1],[3.55,50,50.94,1,1],[3.65,50,48.06,1,1],[3.75,50,43.06,1,1],[3.85,50,35.39,1,1],[3.9,50,30.39,1,1],[4,50,17.72,1,1],[4.05,50,9.94,1,1],[4.15,50,-7.17,1,1],[4.2,50,-14.61,1,1],[4.25,50,-21.06,1,1],[4.3,50,-26.5,1,1],[4.4,50,-35.06,1,1],[4.5,50,-40.83,1,1],[4.65,50,-45.5,1,1],[4.8,50,-46.94,1,1],[5,50,-46.94,1,1]] },
buyer4: { size: [744,29], keys: [[0,50,158.83,1,0],[0.1,50,158.83,1,0],[0.15,50,158.83,1,1],[1.25,50,158.83,1,1],[1.35,50,142.17,1,1],[1.45,50,126.94,1,1],[1.5,50,120.06,1,1],[1.6,50,107.83,1,1],[1.7,50,97.28,1,1],[1.8,50,88.39,1,1],[1.9,50,80.94,1,1],[2,50,74.83,1,1],[2.1,50,70.06,1,1],[2.2,50,66.28,1,1],[2.3,50,63.5,1,1],[2.45,50,60.72,1,1],[2.75,50,58.83,1,1],[3.45,50,58.61,1,1],[3.6,50,56.83,1,1],[3.7,50,53.83,1,1],[3.75,50,51.61,1,1],[3.85,50,45.28,1,1],[3.95,50,36.06,1,1],[4.05,50,23.39,1,1],[4.2,50,-1.61,1,1],[4.25,50,-8.94,1,1],[4.3,50,-15.39,1,1],[4.35,50,-20.83,1,1],[4.45,50,-29.39,1,1],[4.55,50,-35.06,1,1],[4.65,50,-38.61,1,1],[4.75,50,-40.39,1,1],[5,50,-41.06,1,1]] },
buyer5: { size: [744,29], keys: [[0,50,164.72,1,0],[0.1,50,164.72,1,0],[0.15,50,164.72,1,1],[1.3,50,164.72,1,1],[1.45,50,139.83,1,1],[1.55,50,125.72,1,1],[1.65,50,113.39,1,1],[1.75,50,102.94,1,1],[1.85,50,94.06,1,1],[1.95,50,86.61,1,1],[2.05,50,80.61,1,1],[2.15,50,75.83,1,1],[2.25,50,72.06,1,1],[2.4,50,68.17,1,1],[2.55,50,65.94,1,1],[2.8,50,64.72,1,1],[3.5,50,64.5,1,1],[3.65,50,62.61,1,1],[3.75,50,59.61,1,1],[3.85,50,54.5,1,1],[3.9,50,50.94,1,1],[4,50,41.72,1,1],[4.05,50,35.83,1,1],[4.1,50,28.94,1,1],[4.15,50,21.06,1,1],[4.25,50,3.94,1,1],[4.3,50,-3.39,1,1],[4.4,50,-15.17,1,1],[4.5,50,-23.61,1,1],[4.6,50,-29.28,1,1],[4.7,50,-32.72,1,1],[4.8,50,-34.5,1,1],[5,50,-35.17,1,1]] },
img: { size: [1056,1170], keys: [[0,50,150,0.3845,0],[0.1,50,150,0.3845,0],[0.15,50,150,0.3845,1],[0.2,50,149.33,0.3845,1],[0.3,50,132.11,0.3845,1],[0.35,50,124.33,0.3845,1],[0.45,50,110.22,0.3845,1],[0.6,50,92.67,0.3845,1],[0.7,50,83.11,0.3845,1],[0.85,50,71.56,0.3845,1],[1,50,63.11,0.3845,1],[1.1,50,58.89,0.3845,0.95],[1.25,50,54.44,0.3845,0.76],[1.5,50,50.78,0.3845,0.48],[1.7,50,50,0.3845,0.31],[1.95,50,49.44,0.3845,0.21],[4,50,44.44,0.3845,0.2],[4.65,50,42.89,0.3845,0.87],[5,50,42,0.3845,1]] },
credit: { size: [178,55], keys: [[0,50,178.06,1,0],[0.1,50,178.06,1,0],[0.15,50,178.06,1,1],[0.2,50,177.39,1,1],[0.3,50,160.17,1,1],[0.35,50,152.39,1,1],[0.45,50,138.28,1,1],[0.6,50,120.72,1,1],[0.7,50,111.17,1,1],[0.85,50,99.61,1,1],[0.95,50,93.72,1,1],[1.1,50,86.94,1,0.95],[1.25,50,82.5,1,0.76],[1.4,50,79.83,1,0.58],[1.5,50,78.83,1,0.48],[1.9,50,77.61,1,0.22],[4,50,72.5,1,0.2],[4.6,50,71.06,1,0.83],[5,50,70.06,1,1]] },
},
phone: {
view: [375, 812],
top: { size: [298,56], keys: [[0,49.87,113.79,1,0],[0.05,49.87,113.79,1,0],[0.1,49.87,113.79,1,1],[0.15,49.87,104.8,1,1],[0.25,49.87,88.55,1,1],[0.35,49.87,74.26,1,1],[0.45,49.87,61.95,1,1],[0.56,49.87,51.48,1,1],[0.66,49.87,42.73,1,1],[0.76,49.87,35.34,1,1],[0.86,49.87,29.43,1,1],[0.96,49.87,24.63,1,1],[1.06,49.87,21.06,1,1],[1.21,49.87,17.24,1,1],[1.46,49.87,14.41,1,1],[3.13,49.87,14.04,1,1],[3.28,49.87,13.3,1,1],[3.38,49.87,11.45,1,1],[3.48,49.87,8,1,1],[3.53,49.87,5.42,1,1],[3.64,49.87,-1.6,1,1],[3.69,49.87,-6.28,1,1],[3.79,49.87,-18.35,1,1],[3.84,49.87,-25.74,1,1],[3.94,49.87,-43.1,1,1],[3.99,49.87,-50.99,1,1],[4.04,49.87,-57.76,1,1],[4.14,49.87,-68.6,1,1],[4.24,49.87,-76.23,1,1],[4.34,49.87,-81.28,1,1],[4.39,49.87,-82.88,1,1],[4.54,49.87,-85.59,1,1],[5,49.87,-85.96,1,1]] },
bottom: { size: [371,56], keys: [[0,50,137.07,1,0],[0.05,50,137.07,1,0],[0.1,50,137.07,1,1],[2.12,50,136.82,1,1],[2.17,50,127.59,1,1],[2.22,50,119.58,1,1],[2.27,50,112.81,1,1],[2.32,50,107.02,1,1],[2.37,50,102.09,1,1],[2.42,50,98.15,1,1],[2.53,50,92.36,1,1],[2.58,50,90.52,1,1],[2.68,50,88.18,1,1],[2.83,50,87.07,1,1],[3.59,50,86.7,1,1],[3.74,50,84.11,1,1],[3.84,50,80.3,1,1],[3.89,50,77.59,1,1],[3.99,50,69.95,1,1],[4.09,50,59.24,1,1],[4.19,50,44.7,1,1],[4.29,50,27.22,1,1],[4.34,50,19.83,1,1],[4.39,50,13.3,1,1],[4.49,50,2.96,1,1],[4.6,50,-4.19,1,1],[4.64,50,-6.77,1,1],[4.75,50,-10.34,1,1],[4.85,50,-12.07,1,1],[5,50,-12.93,1,1]] },
buyer0: { size: [355,24], keys: [[0,50,136.45,1,0],[0.05,50,136.45,1,0],[0.1,50,136.45,1,1],[1.06,50,136.45,1,1],[1.16,50,119.21,1,1],[1.26,50,103.94,1,1],[1.36,50,90.64,1,1],[1.46,50,79.19,1,1],[1.57,50,69.58,1,1],[1.67,50,61.45,1,1],[1.77,50,54.8,1,1],[1.87,50,49.38,1,1],[2.02,50,43.47,1,1],[2.12,50,40.76,1,1],[2.27,50,38.18,1,1],[2.58,50,36.45,1,1],[3.23,50,36.33,1,1],[3.38,50,34.98,1,1],[3.48,50,32.39,1,1],[3.59,50,27.71,1,1],[3.64,50,24.51,1,1],[3.74,50,15.89,1,1],[3.79,50,10.34,1,1],[3.84,50,3.82,1,1],[3.89,50,-3.69,1,1],[3.99,50,-21.06,1,1],[4.04,50,-28.82,1,1],[4.09,50,-35.59,1,1],[4.19,50,-46.43,1,1],[4.29,50,-53.94,1,1],[4.34,50,-56.77,1,1],[4.44,50,-60.59,1,1],[4.54,50,-62.56,1,1],[4.7,50,-63.55,1,1],[5,50,-63.55,1,1]] },
buyer1: { size: [355,24], keys: [[0,50,141.87,1,0],[0.05,50,141.87,1,0],[0.1,50,141.87,1,1],[1.11,50,141.63,1,1],[1.16,50,132.64,1,1],[1.26,50,116.38,1,1],[1.36,50,102.09,1,1],[1.46,50,89.9,1,1],[1.57,50,79.31,1,1],[1.67,50,70.57,1,1],[1.77,50,63.18,1,1],[1.87,50,57.27,1,1],[1.97,50,52.46,1,1],[2.12,50,47.41,1,1],[2.22,50,45.07,1,1],[2.37,50,42.98,1,1],[2.63,50,41.87,1,1],[3.28,50,41.75,1,1],[3.43,50,40.27,1,1],[3.53,50,37.68,1,1],[3.64,50,33,1,1],[3.69,50,29.8,1,1],[3.79,50,21.06,1,1],[3.89,50,8.87,1,1],[3.94,50,1.35,1,1],[4.04,50,-16.01,1,1],[4.09,50,-23.77,1,1],[4.14,50,-30.42,1,1],[4.24,50,-41.13,1,1],[4.34,50,-48.65,1,1],[4.44,50,-53.57,1,1],[4.54,50,-56.4,1,1],[4.64,50,-57.76,1,1],[5,50,-58.13,1,1]] },
buyer2: { size: [355,24], keys: [[0,50,147.29,1,0],[0.05,50,147.29,1,0],[0.1,50,147.29,1,1],[1.11,50,147.29,1,1],[1.16,50,146.55,1,1],[1.26,50,129.31,1,1],[1.36,50,114.04,1,1],[1.46,50,100.86,1,1],[1.52,50,94.95,1,1],[1.67,50,79.93,1,1],[1.77,50,71.92,1,1],[1.92,50,62.56,1,1],[2.07,50,55.91,1,1],[2.17,50,52.71,1,1],[2.27,50,50.49,1,1],[2.42,50,48.4,1,1],[2.68,50,47.29,1,1],[3.33,50,47.17,1,1],[3.48,50,45.69,1,1],[3.59,50,42.98,1,1],[3.69,50,38.3,1,1],[3.74,50,34.98,1,1],[3.79,50,31.03,1,1],[3.84,50,26.23,1,1],[3.89,50,20.57,1,1],[3.94,50,14.04,1,1],[3.99,50,6.4,1,1],[4.09,50,-10.96,1,1],[4.14,50,-18.72,1,1],[4.24,50,-31.03,1,1],[4.34,50,-40.02,1,1],[4.39,50,-43.35,1,1],[4.49,50,-48.28,1,1],[4.64,50,-51.85,1,1],[4.8,50,-52.71,1,1],[5,50,-52.71,1,1]] },
buyer3: { size: [355,24], keys: [[0,50,152.71,1,0],[0.05,50,152.71,1,0],[0.1,50,152.71,1,1],[1.16,50,152.71,1,1],[1.21,50,151.6,1,1],[1.26,50,142.61,1,1],[1.36,50,126.48,1,1],[1.46,50,112.32,1,1],[1.57,50,100.12,1,1],[1.67,50,89.78,1,1],[1.77,50,81.03,1,1],[1.92,50,70.57,1,1],[2.02,50,65.39,1,1],[2.12,50,61.21,1,1],[2.27,50,56.9,1,1],[2.42,50,54.31,1,1],[2.73,50,52.71,1,1],[3.38,50,52.59,1,1],[3.53,50,51.11,1,1],[3.64,50,48.4,1,1],[3.69,50,46.31,1,1],[3.79,50,40.27,1,1],[3.89,50,31.4,1,1],[3.99,50,19.09,1,1],[4.04,50,11.45,1,1],[4.14,50,-5.91,1,1],[4.24,50,-20.2,1,1],[4.29,50,-25.99,1,1],[4.39,50,-34.85,1,1],[4.49,50,-40.76,1,1],[4.54,50,-42.86,1,1],[4.64,50,-45.69,1,1],[4.75,50,-46.92,1,1],[5,50,-47.29,1,1]] },
buyer4: { size: [355,24], keys: [[0,50,158.13,1,0],[0.05,50,158.13,1,0],[0.1,50,158.13,1,1],[1.21,50,158.13,1,1],[1.26,50,156.53,1,1],[1.36,50,139.29,1,1],[1.41,50,131.53,1,1],[1.46,50,124.26,1,1],[1.57,50,111.08,1,1],[1.67,50,99.88,1,1],[1.77,50,90.39,1,1],[1.87,50,82.39,1,1],[1.97,50,75.86,1,1],[2.12,50,68.47,1,1],[2.22,50,64.9,1,1],[2.37,50,61.21,1,1],[2.47,50,59.73,1,1],[2.78,50,58.13,1,1],[3.48,50,57.76,1,1],[3.59,50,56.4,1,1],[3.69,50,53.69,1,1],[3.79,50,48.89,1,1],[3.84,50,45.57,1,1],[3.94,50,36.58,1,1],[3.99,50,30.79,1,1],[4.04,50,24.14,1,1],[4.09,50,16.5,1,1],[4.19,50,-0.99,1,1],[4.24,50,-8.5,1,1],[4.34,50,-20.81,1,1],[4.44,50,-29.56,1,1],[4.54,50,-35.47,1,1],[4.64,50,-39.16,1,1],[4.8,50,-41.5,1,1],[5,50,-41.87,1,1]] },
buyer5: { size: [355,24], keys: [[0,50,163.55,1,0],[0.05,50,163.55,1,0],[0.1,50,163.55,1,1],[1.26,50,163.55,1,1],[1.31,50,161.58,1,1],[1.36,50,152.71,1,1],[1.46,50,136.58,1,1],[1.57,50,122.54,1,1],[1.67,50,110.47,1,1],[1.77,50,100.12,1,1],[1.87,50,91.5,1,1],[1.97,50,84.24,1,1],[2.07,50,78.45,1,1],[2.17,50,73.77,1,1],[2.27,50,70.2,1,1],[2.47,50,65.76,1,1],[2.68,50,63.92,1,1],[2.83,50,63.55,1,1],[3.53,50,63.18,1,1],[3.64,50,61.82,1,1],[3.74,50,58.99,1,1],[3.84,50,54.19,1,1],[3.89,50,50.74,1,1],[3.99,50,41.75,1,1],[4.09,50,29.19,1,1],[4.14,50,21.55,1,1],[4.24,50,4.06,1,1],[4.29,50,-3.45,1,1],[4.34,50,-9.98,1,1],[4.39,50,-15.64,1,1],[4.49,50,-24.26,1,1],[4.6,50,-30.17,1,1],[4.7,50,-33.74,1,1],[4.85,50,-36.08,1,1],[5,50,-36.45,1,1]] },
img: { size: [953,1056], keys: [[0,50,150,0.3851,0],[0.05,50,150,0.3851,0],[0.1,50,150,0.3851,1],[0.15,50,150,0.3851,1],[0.2,50,148.89,0.3851,1],[0.25,50,140.02,0.3851,1],[0.35,50,123.77,0.3851,1],[0.45,50,109.61,0.3851,1],[0.56,50,97.41,0.3851,1],[0.61,50,92,0.3851,1],[0.76,50,78.33,0.3851,1],[0.91,50,67.86,0.3851,1],[1.06,50,60.47,0.3851,1],[1.21,50,55.42,0.3851,0.81],[1.31,50,53.2,0.3851,0.68],[1.41,50,51.6,0.3851,0.56],[1.57,50,50.37,0.3851,0.41],[1.97,50,49.38,0.3851,0.2],[4.04,50,44.33,0.3851,0.2],[4.64,50,42.86,0.3851,0.87],[5,50,42.01,0.3852,1]] },
credit: { size: [149,47], keys: [[0,50,177.89,1,0],[0.05,50,177.89,1,0],[0.1,50,177.89,1,1],[0.15,50,177.89,1,1],[0.2,50,176.79,1,1],[0.3,50,159.54,1,1],[0.4,50,144.4,1,1],[0.51,50,131.22,1,1],[0.66,50,114.96,1,1],[0.76,50,106.22,1,1],[0.86,50,98.95,1,1],[0.96,50,93.04,1,1],[1.11,50,86.39,1,0.94],[1.26,50,82.08,1,0.74],[1.36,50,80.23,1,0.62],[1.52,50,78.63,1,0.46],[1.92,50,77.4,1,0.21],[4.04,50,72.23,1,0.2],[4.6,50,70.87,1,0.83],[4.8,50,70.38,1,0.96],[5,50,69.89,1,0.98]] },
},
}
// where the sequence starts once the stage pins: the first name is already entering the view at the bottom
const START = 0.45
/** View heights of scroll the whole sequence takes once the stage pins. */
export const PICTURE_TRAIL_LENGTH = 5 - START
const DISPLAY = "font-heading text-[length:min(calc(var(--story-u)*23),25cqh)] leading-none font-normal tracking-[-0.03em] whitespace-nowrap max-[900px]:text-[length:calc(var(--story-u)*5.6)]"
const H2 = "font-heading text-[length:calc(var(--story-u)*6)] leading-[1.2] font-normal tracking-[-0.02em] max-[900px]:text-[length:calc(var(--story-u)*2.8)] max-[900px]:leading-none"
// every moving part sits at the stage's top left and is moved by translate + scale
const PART = "invisible absolute top-0 left-0 origin-center will-change-transform"
/** The layout for a box: a wide one from 900px across, else a tablet, or a phone below an aspect of 13 : 20. */
export function layoutFor(width: number, height: number): PictureTrailLayout {
if (width >= 900) return "desktop"
return width / Math.max(1, height) >= 13 / 20 ? "tablet" : "phone"
}
/** A track's values at `v` view heights after the stage pins, linearly between keys and held past either end:
* [centre x, centre y, scale, opacity] into out. */
export function trackAt(track: Track, v: number, out: number[]) {
const keys = track.keys
let a = keys[0]!
let b = a
if (v >= keys[keys.length - 1]![0]!) a = b = keys[keys.length - 1]!
else if (v > a[0]!) {
let lo = 0
let hi = keys.length - 1
while (hi - lo > 1) {
const m = (lo + hi) >> 1
if (keys[m]![0]! <= v) lo = m
else hi = m
}
a = keys[lo]!
b = keys[hi]!
}
const f = b[0]! > a[0]! ? (v - a[0]!) / (b[0]! - a[0]!) : 0
for (let c = 1; c <= 4; c++) {
const from = a[c] ?? 1
out[c - 1] = from + ((b[c] ?? 1) - from) * f
}
return out
}
/** Whether a timed part is on at `v` (inside one of its windows). */
export const isOn = (track: Track, v: number) => (track.on ?? []).some(([from, to]) => v >= from && v < to)
/** The nearest ancestor that scrolls vertically, or null when the page itself does. */
function scrollParent(node: HTMLElement): HTMLElement | null {
for (let el = node.parentElement; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
const { overflowY } = getComputedStyle(el)
if (overflowY === "auto" || overflowY === "scroll") return el
}
return null
}
/**
* A picture's trail through time, told as the reader scrolls on a pinned stage: a huge name crosses the view, the
* holders pass in turn beside the picture, and a second huge name follows and stays faded behind it. Pins to its
* nearest scroll container, or to the page. Only transform and opacity change as it scrolls; with reduced motion
* it rests at its end.
*/
export function PictureTrail({
top = "Conqueror",
bottom = "Last ruler",
holders = ["First dynasty", "Second dynasty", "Third dynasty", "Fourth dynasty", "Fifth dynasty", "Sixth dynasty"],
image = {
src: "https://picsum.photos/seed/mewo-picture-trail-1/1330/1478",
credit: { title: "The siege", by: "c. 1258 CE", line: "A museum somewhere. Placeholder photograph.", href: "#" },
},
className,
style,
...props
}: PictureTrailProps) {
const stageRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const stage = stageRef.current
// The root's ref stays the consumer's, so the section is reached through the stage.
const section = stage?.parentElement
if (!stage || !section) return
const scroller = scrollParent(section)
const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
const parts = [...stage.querySelectorAll<HTMLElement>("[data-part]")]
const out = [0, 0, 1, 1]
type Bound = { el: HTMLElement; part: Part; w: number; h: number; y: number; h2: number; last: string }
let bound: Bound[] = []
let layout: PictureTrailLayout = "desktop"
let width = 1
let height = 1
let near = true
let raf = 0
const measure = () => {
// Inside a scroll container the stage pins to that container, not the window.
if (scroller) section.style.setProperty("--mewo-viewport", `${scroller.clientHeight}px`)
width = Math.max(1, stage.clientWidth)
height = Math.max(1, stage.clientHeight)
layout = layoutFor(width, height)
bound = parts.map((el) => ({ el, part: el.dataset.part as Part, w: el.offsetWidth, h: el.offsetHeight, y: 0, h2: 0, last: "" }))
}
const update = () => {
raf = 0
const viewport = scroller ? scroller.clientHeight : window.innerHeight
const top = section.getBoundingClientRect().top - (scroller ? scroller.getBoundingClientRect().top : 0)
const v = START + (reduced ? PICTURE_TRAIL_LENGTH : -top / Math.max(1, viewport))
const set = TRACKS[layout]
const vw = width / 100
const vh = height / 100
let picture: Bound | undefined
for (const b of bound) {
const track = set[b.part]
if (!track) continue
if (track.on) {
// a timed part sits still and fades by its windows
const on = isOn(track, v)
if (on !== b.el.hasAttribute("data-on")) b.el.toggleAttribute("data-on", on)
}
const [cx, cy, s, o] = trackAt(track, v, out) as [number, number, number, number]
const x = cx * vw - b.w / 2
let y = cy * vh - b.h / 2
// the credit hangs under the picture, whatever the box's shape
if (b.part === "credit" && picture) y = picture.y + (picture.h * (1 + picture.h2)) / 2 + vh
if (b.part === "img") {
picture = b
b.y = y
b.h2 = s
}
const key = `${x.toFixed(1)}|${y.toFixed(1)}|${s.toFixed(4)}|${o.toFixed(3)}`
if (key === b.last) continue
b.last = key
// position through translate, so a CSS scale on the part (the timed line's entrance) scales it about its centre
b.el.style.translate = `${x.toFixed(2)}px ${y.toFixed(2)}px`
b.el.style.transform = `scale(${s.toFixed(4)})`
if (track.on) continue
b.el.style.opacity = o.toFixed(3)
b.el.style.visibility = o < 0.002 ? "hidden" : "visible"
}
}
const schedule = () => {
if (!raf && near) raf = requestAnimationFrame(update)
}
const remeasure = () => {
measure()
schedule()
}
measure()
schedule()
const resizeObserver = new ResizeObserver(remeasure)
resizeObserver.observe(stage)
if (scroller) resizeObserver.observe(scroller)
document.fonts?.ready.then(remeasure)
// far from view it doesn't measure on every scroll (against the window, so a scroll box leaving the page counts)
const intersection = new IntersectionObserver(
(entries) => {
near = entries.some((entry) => entry.isIntersecting)
if (near) {
for (const b of bound) b.last = ""
schedule()
}
},
{ rootMargin: "50% 0px" }
)
intersection.observe(section)
const target = scroller ?? window
target.addEventListener("scroll", schedule, { passive: true })
return () => {
cancelAnimationFrame(raf)
resizeObserver.disconnect()
intersection.disconnect()
target.removeEventListener("scroll", schedule)
}
}, [holders.length])
const alt = image.alt ?? `${image.credit.title}, ${image.credit.by}`
return (
<section
data-slot="picture-trail"
className={cn(
"[--story-u:min(2.6667cqw,12px)] min-[901px]:[--story-u:0.625cqw] min-[901px]:@max-[900px]:[--story-u:min(2.6667cqw,12px)] text-[length:var(--story-u)]",
"relative h-[calc(var(--mewo-viewport,100vh)*var(--mewo-length))]",
className
)}
style={{ "--mewo-length": PICTURE_TRAIL_LENGTH + 1, ...style } as React.CSSProperties}
{...props}
>
<div ref={stageRef} data-slot="picture-trail-stage" className="sticky top-0 h-[var(--mewo-viewport,100vh)] w-full overflow-hidden [container-type:size]">
<p data-part="top" className={cn(PART, DISPLAY)}>
{top}
</p>
<p data-part="bottom" className={cn(PART, DISPLAY)}>
{bottom}
</p>
{holders.slice(0, 6).map((h, i) => (
<p
key={i}
data-part={`buyer${i}`}
className={cn(
PART,
"w-[calc(var(--story-u)*45)] max-w-full max-[900px]:w-[calc(100cqw-calc(var(--story-u)*2))] max-[900px]:text-center max-[900px]:text-[length:calc(var(--story-u)*2.4)] max-[900px]:leading-none max-[900px]:whitespace-nowrap",
H2
)}
>
{h}
</p>
))}
<div data-part="img" className={cn(PART, "aspect-[2000/2215] w-[100cqw] max-[900px]:h-[130cqh] max-[900px]:w-auto")}>
{/* eslint-disable-next-line @next/next/no-img-element -- items cannot use next/image */}
<img src={image.src} alt={alt} decoding="async" className="absolute inset-0 size-full object-cover" />
</div>
<p data-part="credit" className={cn(PART, "grid w-[calc(var(--story-u)*19.8)] gap-[calc(var(--story-u)*0.5)] text-center max-[900px]:w-[calc(var(--story-u)*15)]")}>
<span className="text-[length:max(12px,calc(var(--story-u)*1.2))] leading-[1.2] max-[900px]:text-[length:calc(var(--story-u)*1)]">
<a href={image.credit.href} target="_blank" rel="noopener noreferrer" className="pointer-events-auto">
<em className="not-italic underline underline-offset-[0.15em]">{image.credit.title}</em>
</a>
, {image.credit.by}
</span>
<span className="text-[10px] leading-[1.25] text-muted-foreground">{image.credit.line}</span>
</p>
</div>
</section>
)
}