Names — Duel
Two huge names and the pictures between them, played by scroll on a pinned stage: the first rests at the top and shrinks away as the second grows into its place. Pins to its scroll container or the page.
No dependencies
Seen inBetween Rivers
0px
Customize
Length3.1 screens
Install
Terminal
$
npx shadcn@latest add @mewo/names-duelNot using the registry directory?
This section installs other @mewo items, so the namespace has to be in your components.json. Add it, then run the command above:
{
"registries": {
"@mewo": "https://www.mewo-ui.com/r/{name}.json"
}
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
top | string | "First ruler" | The name that rises first, rests at the top, then shrinks away. |
bottom | string | "Second ruler" | The name that follows it, small, and grows into its place. |
pictures | { src; ratio; alt?; credit? }[] | one placeholder | One picture, or two side by side as layers (a third is left out), each with an optional credit under it. |
length | number | 3.1 | Viewport heights of scroll the duel plays over once its stage is pinned. |
className | string | — | Merged onto the section. |
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { PictureCredit } from "@/components/ui/picture-credit"
import { PictureFigure } from "@/components/ui/picture-figure"
export interface NamesDuelPicture {
src: string
/** Width ÷ height. */
ratio: number
alt?: string
/** The credit hanging under it. */
credit?: { title: string; by: string; line: string; href?: string }
}
export interface NamesDuelProps extends Omit<React.ComponentProps<"section">, "children"> {
/** The name that rises first, rests at the top, then shrinks away. */
top?: string
/** The name that follows it, small, and grows into its place. */
bottom?: string
/** One picture, or two side by side as layers, passing between the names. */
pictures?: NamesDuelPicture[]
/** Viewport heights of scroll the duel plays over once its stage is pinned. */
length?: number
}
type Key = readonly number[]
type Motion = {
top: Key[]
bottom: Key[]
one: Key[]
first: { x: number; y: Key[] }
second: { x: number; y: Key[] }
}
// The motion, in screens of scroll from the moment the top name starts to rise: [t, centre y (% of the stage's
// height), scale]. Pictures keep their size; two pictures sit at their own x (% of the width), as layers.
const MOTION: Record<"wide" | "narrow", Motion> = {
wide: {
top: [[0, 120.4, 1], [0.1, 120.4, 1], [0.2, 101.6, 1], [0.3, 85.4, 1], [0.45, 65.4, 1], [0.6, 49.9, 1], [0.7, 41.9, 1], [0.8, 35.5, 1], [0.9, 30.5, 1], [1.1, 24.1, 1], [1.3, 21.2, 0.998], [1.45, 20.2, 0.986], [1.6, 19.6, 0.943], [1.7, 18.9, 0.889], [1.8, 17.7, 0.807], [1.9, 16.3, 0.695], [2, 14.4, 0.554], [2.05, 13.6, 0.494], [2.1, 12.8, 0.442], [2.2, 11.6, 0.361], [2.3, 10.3, 0.307], [2.45, 7.8, 0.264], [2.6, 4, 0.251], [2.7, 0.2, 0.25], [2.8, -4.8, 0.25], [2.9, -11.2, 0.25], [3.05, -24, 0.25], [3.2, -40.9, 0.25], [3.35, -62.6, 0.25]],
bottom: [[0, 190.6, 0.25], [0.25, 190.6, 0.25], [0.3, 180.9, 0.25], [0.4, 163.4, 0.25], [0.5, 148.4, 0.25], [0.6, 135.7, 0.25], [0.7, 124.9, 0.25], [0.8, 116, 0.25], [0.9, 108.8, 0.25], [1, 103.1, 0.25], [1.15, 97, 0.25], [1.3, 93.3, 0.251], [1.5, 90.8, 0.274], [1.6, 90.1, 0.307], [1.7, 89.5, 0.36], [1.8, 88.7, 0.442], [1.9, 87.6, 0.554], [2, 86.3, 0.695], [2.05, 85.7, 0.756], [2.15, 84.8, 0.852], [2.25, 84, 0.918], [2.3, 83.6, 0.943], [2.4, 82.8, 0.975], [2.5, 81.6, 0.992], [2.65, 78.4, 0.999], [2.8, 73.2, 1], [2.9, 68.2, 1], [3, 61.7, 1], [3.1, 53.7, 1], [3.2, 43.9, 1], [3.3, 32.2, 1], [3.4, 18.3, 1], [3.5, 2.1, 1], [3.6, -16.6, 1], [4, -16.6, 1]],
one: [[0, 147.3], [0.05, 147.3], [0.2, 122.1], [0.3, 108.2], [0.4, 96.4], [0.55, 82.4], [0.65, 75.2], [0.75, 69.5], [0.9, 63.4], [1.05, 59.7], [1.2, 57.8], [1.55, 56.3], [1.75, 54.3], [2.05, 47.5], [2.65, 38.3], [2.85, 32.5], [3, 25.8], [3.15, 16.9], [3.25, 9.5], [3.35, 0.8], [3.45, -9.3], [3.55, -20.8], [3.7, -41.3], [3.8, -56.9], [4, -56.9]],
first: { x: 57.7, y: [[0, 151.4], [0.05, 151.4], [0.15, 133.9], [0.25, 118.9], [0.35, 106.1], [0.45, 95.3], [0.6, 82.6], [0.75, 73.5], [0.9, 67.4], [1.05, 63.7], [1.2, 61.8], [1.5, 60.6], [1.75, 58.3], [2.15, 49.7], [2.6, 43.4], [2.85, 36.5], [3, 29.8], [3.15, 20.9], [3.25, 13.5], [3.35, 4.8], [3.5, -10.8], [3.6, -23.2], [3.7, -37.2], [3.8, -52.9], [4, -52.9]] },
second: { x: 38.6, y: [[0, 140.5], [0.05, 140.5], [0.15, 123.2], [0.25, 108.1], [0.35, 95.3], [0.45, 84.5], [0.6, 71.8], [0.7, 65.4], [0.8, 60.4], [0.9, 56.6], [1.05, 52.9], [1.2, 51.1], [1.55, 49.5], [1.75, 47.5], [2.05, 40.7], [2.65, 31.5], [2.85, 25.7], [3.05, 16.4], [3.15, 10.2], [3.25, 2.7], [3.35, -5.9], [3.45, -16.1], [3.55, -27.6], [3.65, -40.8], [3.8, -63.7]] },
},
narrow: {
top: [[0, 115, 1], [0.1, 115, 1], [0.15, 107.9, 1], [0.25, 89.9, 1], [0.3, 81.9, 1], [0.45, 61.3, 1], [0.61, 45.4, 1], [0.71, 37.2, 1], [0.81, 30.5, 1], [0.96, 23.4, 1], [1.16, 17.7, 1], [1.31, 15.8, 1], [1.46, 15.1, 0.991], [1.56, 14.9, 0.974], [1.67, 14.8, 0.942], [1.82, 14.4, 0.858], [1.92, 14.1, 0.774], [2.07, 13.5, 0.626], [2.22, 12.9, 0.527], [2.37, 11.5, 0.475], [2.58, 7.6, 0.452], [2.68, 4.3, 0.449], [2.78, -0.3, 0.449], [2.88, -6.2, 0.449], [3.03, -18.2, 0.449], [3.13, -28.4, 0.449], [3.23, -40.6, 0.449], [3.38, -63.1, 0.449]],
bottom: [[0, 191.7, 0.45], [0.25, 191.7, 0.45], [0.3, 184.3, 0.45], [0.35, 174.9, 0.45], [0.45, 158.3, 0.45], [0.61, 137.8, 0.45], [0.76, 122, 0.45], [0.86, 113.7, 0.45], [1.01, 104.4, 0.45], [1.11, 99.9, 0.45], [1.26, 95.4, 0.45], [1.46, 92.5, 0.459], [1.62, 91.8, 0.491], [1.72, 91.5, 0.531], [1.77, 91.3, 0.56], [1.92, 91, 0.676], [2.07, 90.6, 0.827], [2.17, 90.4, 0.896], [2.27, 90.2, 0.943], [2.37, 89.7, 0.975], [2.52, 88, 0.997], [2.73, 83.4, 1], [2.88, 77, 1], [2.98, 70.9, 1], [3.08, 63.4, 1], [3.18, 54.2, 1], [3.28, 43, 1], [3.38, 29.7, 1], [3.48, 14, 1], [3.58, -4.1, 1], [3.63, -9.8, 1], [3.99, -9.8, 1]],
one: [[0, 153.1], [0.05, 153.1], [0.15, 135.1], [0.25, 119.6], [0.4, 100.6], [0.56, 86.2], [0.71, 75.6], [0.86, 68.3], [1.01, 63.8], [1.21, 60.8], [1.56, 59], [1.72, 57], [1.87, 53.3], [2.12, 45], [2.68, 35.1], [2.93, 27], [3.03, 22], [3.18, 12.8], [3.28, 5], [3.38, -3.9], [3.48, -14.4], [3.58, -26.4], [3.69, -40], [3.79, -55.4], [3.84, -60]],
first: { x: 68.8, y: [[0, 157.8], [0.05, 157.8], [0.2, 132.3], [0.3, 118.3], [0.4, 106.4], [0.5, 96.5], [0.6, 88.5], [0.71, 82.1], [0.86, 75.1], [1.01, 70.8], [1.21, 68], [1.61, 65.6], [1.77, 63], [2.07, 53.2], [2.22, 49.8], [2.68, 42], [2.83, 37.7], [2.93, 33.6], [3.03, 28.7], [3.18, 19.2], [3.28, 11.3], [3.38, 2.1], [3.48, -8.6], [3.58, -20.8], [3.69, -34.7], [3.79, -50.4], [3.84, -52.7], [3.99, -52.7]] },
second: { x: 22.4, y: [[0, 141.1], [0.05, 141.1], [0.2, 115.6], [0.3, 101.7], [0.4, 89.9], [0.56, 75.7], [0.71, 65.5], [0.86, 58.6], [1.01, 54.2], [1.16, 51.9], [1.56, 49.6], [1.72, 47.4], [1.87, 43.7], [2.12, 35.3], [2.63, 26.6], [2.88, 19.2], [3.08, 9.2], [3.18, 2.6], [3.33, -9.7], [3.43, -19.7], [3.53, -31], [3.63, -44.1], [3.74, -58.9]] },
},
}
// at the moment the stage pins, the top name has just arrived; the motion ends with everything above the stage
const LEAD = 0.9
const END = 4
/** A curve's value at t (linear between keys, held at the ends): [y, scale]. */
export function sampleCurve(keys: readonly Key[], t: number): [number, number] {
const first = keys[0]!
const last = keys[keys.length - 1]!
if (t <= first[0]!) return [first[1]!, first[2] ?? 1]
if (t >= last[0]!) return [last[1]!, last[2] ?? 1]
let i = 1
while (keys[i]![0]! < t) i++
const a = keys[i - 1]!
const b = keys[i]!
const f = (t - a[0]!) / (b[0]! - a[0]!)
return [a[1]! + (b[1]! - a[1]!) * f, (a[2] ?? 1) + ((b[2] ?? 1) - (a[2] ?? 1)) * f]
}
/** 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
}
const pic = (n: number, w: number, h: number) => `https://picsum.photos/seed/mewo-names-duel-${n}/${w}/${h}`
const PIECE = "absolute top-0 -translate-x-1/2 will-change-transform motion-reduce:static motion-reduce:translate-x-0 motion-reduce:will-change-auto"
const NAME = "font-heading text-[length:min(14.375cqw,25cqh)] leading-none font-normal tracking-[-0.03em] whitespace-nowrap @max-[900px]:text-[length:min(14.93cqw,67px)]"
// picture heights: one picture, or the first and second of two
const HEIGHTS = [
["h-[45cqh] @max-[900px]:h-[min(80cqw,57cqh)]"],
["h-[37cqh] @max-[900px]:h-[min(55cqw,34.8cqh)]", "h-[31.4cqh] @max-[900px]:h-[min(46cqw,40cqh)]"],
]
/**
* Two huge names and the pictures between them, played by scroll on a pinned stage. The first name rises and rests at
* the top while one picture, or two as layers, drifts up; the second name follows small, and as the first shrinks
* away it grows into its place; then everything leaves upward. Pins to its nearest scroll container, or to the page.
* With reduced motion it is a still column: name, pictures, name.
*/
export function NamesDuel({
top = "First ruler",
bottom = "Second ruler",
pictures = [{ src: pic(1, 1000, 978), ratio: 1000 / 978, credit: { title: "A carved disc", by: "c. 2300 BCE", line: "A museum somewhere. Placeholder photograph." } }],
length = END - LEAD,
className,
style,
...props
}: NamesDuelProps) {
const stageRef = React.useRef<HTMLDivElement>(null)
const topRef = React.useRef<HTMLParagraphElement>(null)
const bottomRef = React.useRef<HTMLParagraphElement>(null)
const pictureRefs = React.useRef<(HTMLElement | null)[]>([])
const shown = pictures.slice(0, 2)
const count = shown.length
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 || matchMedia("(prefers-reduced-motion: reduce)").matches) return
const scroller = scrollParent(section)
const span = Math.max(0.1, length)
let raf = 0
let last = NaN
const place = (el: HTMLElement | null | undefined, [y, s]: [number, number]) => {
if (!el) return
el.style.translate = `-50% calc(${y.toFixed(2)}cqh - 50%)`
el.style.scale = s.toFixed(3)
}
const update = () => {
raf = 0
// inside a scroll container the stage pins to that container, not the window
if (scroller) section.style.setProperty("--mewo-viewport", `${scroller.clientHeight}px`)
const viewport = scroller ? scroller.clientHeight : window.innerHeight
const top = section.getBoundingClientRect().top - (scroller ? scroller.getBoundingClientRect().top : 0)
const t = Math.min(END, LEAD + ((-top / Math.max(1, viewport)) * (END - LEAD)) / span)
if (t === last) return
last = t
const m = MOTION[stage.clientWidth >= 900 ? "wide" : "narrow"]
stage.dataset.ready = ""
place(topRef.current, sampleCurve(m.top, t))
place(bottomRef.current, sampleCurve(m.bottom, t))
if (count === 1) place(pictureRefs.current[0], sampleCurve(m.one, t))
else {
place(pictureRefs.current[0], sampleCurve(m.first.y, t))
place(pictureRefs.current[1], sampleCurve(m.second.y, t))
pictureRefs.current[0]?.style.setProperty("left", `${m.first.x}%`)
pictureRefs.current[1]?.style.setProperty("left", `${m.second.x}%`)
}
}
const schedule = () => {
if (!raf) raf = requestAnimationFrame(update)
}
const onResize = () => {
last = NaN
schedule()
}
schedule()
const target = scroller ?? window
target.addEventListener("scroll", schedule, { passive: true })
window.addEventListener("resize", onResize)
return () => {
cancelAnimationFrame(raf)
target.removeEventListener("scroll", schedule)
window.removeEventListener("resize", onResize)
}
}, [length, count])
return (
<section
data-slot="names-duel"
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))] motion-reduce:h-auto", className)}
style={{ "--mewo-length": 1 + Math.max(0, length), ...style } as React.CSSProperties}
{...props}
>
<div
ref={stageRef}
className={cn(
"sticky top-0 h-[var(--mewo-viewport,100vh)] w-full overflow-hidden [container-type:size]",
// hidden until the first scroll update has placed every piece
"motion-safe:[&:not([data-ready])>*]:invisible",
"motion-reduce:static motion-reduce:flex motion-reduce:h-auto motion-reduce:flex-col motion-reduce:items-center motion-reduce:gap-[calc(var(--story-u)*6)] motion-reduce:py-[calc(var(--story-u)*6)] motion-reduce:[container-type:inline-size]"
)}
>
<p ref={topRef} className={cn(PIECE, NAME, "left-1/2")}>
{top}
</p>
<p ref={bottomRef} className={cn(PIECE, NAME, "left-1/2 scale-25 motion-reduce:scale-100")}>
{bottom}
</p>
{shown.map((p, i) => (
<PictureFigure
key={p.src}
ref={(el) => {
pictureRefs.current[i] = el
}}
src={p.src}
ratio={p.ratio}
alt={p.alt ?? (p.credit ? `${p.credit.title}, ${p.credit.by}` : "")}
caption={p.credit && <PictureCredit {...p.credit} className="gap-[calc(var(--story-u)*0.4)] [&>span:last-child]:leading-[1.2]" />}
className={cn(PIECE, "left-1/2", HEIGHTS[count - 1]![i])}
/>
))}
</div>
</section>
)
}