New
Toast StackStagger
Six coloured panels sweep in, hinge from the far edge, and leave — the title resolves mid-pass. Press to run.
'use client';
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import './curtain-sequence.css';
export type CurtainSequenceProps = {
/** Shown on the plate before the run. */
label?: string;
/** Replaces it while the panels are travelling. */
activeLabel?: string;
/**
* The 298x240 catalogue card: the editorial caption and the 28px viewfinder margin
* go, and the plate presses itself. The composition is all in `curtain-sequence.css`;
* the self-running part is the effect below.
*/
compact?: boolean;
};
/** Six panels, not a prop: the stylesheet times each one by `nth-child`. */
const PANELS = 6;
/** One run, end to end: 2.45s of travel plus the last panel's 375ms of stagger. */
const RUN_MS = 2850;
/**
* The still frame a card holds between runs. Long enough for the title to land and be
* read once the panels have left, short enough that the wipe — the reason the card is
* worth looking at — is running about three quarters of the time.
*/
const CARD_REST_MS = 1000;
export function CurtainSequence({
label = 'CURTAIN',
activeLabel = 'SEQUENCE',
compact = false,
}: CurtainSequenceProps = {}) {
const [running, setRunning] = useState(false);
const run = () => {
if (running) return;
setRunning(true);
window.setTimeout(() => setRunning(false), RUN_MS);
};
/*
* In a card the plate presses itself. There is no solver here to be alive on its own:
* the six panels sit turned away from the viewer — `rotateY(-92deg)`, two degrees past
* the point `backface-visibility: hidden` stops drawing them — until something sets
* `running`, so a card of this stage is a still, which is the presentation the card
* variants exist to replace. Flipping the same state a click flips keeps the label swap,
* the z-index lift and the authored 2.85s exactly as they are. A press during the rest
* beat leaves its own timer alongside this one: both write `false` at the same point of
* the same run, so the overlap is a duplicate write rather than a second run.
*
* Left alone under `prefers-reduced-motion`: a full-bleed wipe restarting every four
* seconds, unasked, in a grid of these is what that setting is for. The still frame is
* what remains there, and pressing it still runs it. Read per cycle rather than latched
* into state, so switching the setting on stops the card at the next flip and there is
* no listener to add for it.
*/
useEffect(() => {
if (!compact) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const timer = window.setTimeout(
() => setRunning(!running),
running ? RUN_MS : CARD_REST_MS,
);
return () => window.clearTimeout(timer);
}, [compact, running]);
return (
<div className="curtain-sequence-stage" data-compact={compact ? 'true' : undefined}>
<button
type="button"
className={`curtain-sequence ${running ? 'running' : ''}`}
onClick={run}
aria-label="Run the curtain transition"
// The card frame is aria-hidden and the card's own title link is the way in, so
// the plate leaves the tab order there rather than sitting as a focusable node
// inside a hidden box. It stays clickable in both.
tabIndex={compact ? -1 : undefined}
// The stylesheet divides the stage width by `--n` to size the plate. Both
// labels swap into the same slot, so the longer one sets the size.
style={
{ '--n': Math.max(label.length, activeLabel.length) } as CSSProperties
}
>
<span>{running ? 'Sequence running' : 'Press to run the sequence'}</span>
<div aria-hidden="true">
{Array.from({ length: PANELS }, (_, index) => (
<i style={{ '--panel': index } as CSSProperties} key={index}>
<small>{String(index + 1).padStart(2, '0')}</small>
</i>
))}
</div>
<b>
<em>{running ? activeLabel : label}</em>
<small>{running ? 'FRAME LOCKED' : 'EDITORIAL TRANSITION'}</small>
</b>
</button>
</div>
);
}@keyframes curtain-panel {
0% {
transform: translateX(-135%) rotateY(-92deg);
transform-origin: left center;
}
35%,
59% {
transform: translateX(0) rotateY(0);
transform-origin: left center;
}
60% {
transform-origin: right center;
}
100% {
transform: translateX(135%) rotateY(92deg);
transform-origin: right center;
}
}
@keyframes curtain-title {
0%,
24% {
opacity: 0;
transform: scale(0.72);
filter: blur(12px);
color: #f0ede6;
}
43%,
62% {
opacity: 1;
transform: scale(1);
filter: blur(0);
color: #111;
}
76%,
100% {
opacity: 0;
transform: scale(1.18);
filter: blur(8px);
color: #f0ede6;
}
}
@keyframes curtain-index {
0%,
28% {
opacity: 0.18;
transform: translateY(0);
}
45%,
66% {
opacity: 1;
transform: translateY(118px);
color: #111;
}
100% {
opacity: 0.18;
transform: translateY(0);
}
}
/*
* The surface the sequence is designed to sit on. `perspective` lives here as
* well as on the button so the panels keep their depth if the stage is reused.
*/
.curtain-sequence-stage {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 100%;
min-height: 240px;
container-type: inline-size;
overflow: hidden;
background: #0d0d0c;
perspective: 1100px;
}
.curtain-sequence {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border: 0;
background: radial-gradient(circle at 50% 46%, #2a2a27 0, #111 58%, #080807 100%);
color: #f0ede6;
perspective: 1100px;
}
.curtain-sequence::before {
content: '';
position: absolute;
inset: 28px;
border: 1px solid rgba(255, 255, 255, 0.16);
background:
linear-gradient(
90deg,
transparent 49.9%,
rgba(255, 255, 255, 0.08) 50%,
transparent 50.1%
),
linear-gradient(
0deg,
transparent 49.9%,
rgba(255, 255, 255, 0.08) 50%,
transparent 50.1%
);
}
.curtain-sequence::after {
content: '12';
position: absolute;
right: 17px;
top: 12px;
color: rgba(255, 255, 255, 0.18);
font-size: 54px;
font-weight: 750;
letter-spacing: -0.08em;
}
.curtain-sequence > span {
position: absolute;
z-index: 9;
left: 17px;
bottom: 15px;
font: 8px ui-monospace, SFMono-Regular, Menlo, monospace;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.curtain-sequence > div {
position: absolute;
z-index: 4;
inset: 0;
display: flex;
perspective: 900px;
pointer-events: none;
}
.curtain-sequence > div > i {
--delay: calc(var(--panel) * 75ms);
position: relative;
width: 16.667%;
height: 100%;
overflow: hidden;
border-right: 1px solid rgba(17, 17, 15, 0.38);
background: #d8ff43;
transform: translateX(-130%) rotateY(-92deg);
transform-origin: left center;
box-shadow: 16px 0 34px rgba(0, 0, 0, 0.22);
backface-visibility: hidden;
}
.curtain-sequence > div > i:nth-child(2) {
background: #f0ede6;
}
.curtain-sequence > div > i:nth-child(3) {
background: #3155e7;
}
.curtain-sequence > div > i:nth-child(4) {
background: #ff5a40;
}
.curtain-sequence > div > i:nth-child(5) {
background: #9b6dff;
}
.curtain-sequence > div > i:nth-child(6) {
background: #111;
color: white;
}
.curtain-sequence i > small {
position: absolute;
left: 50%;
bottom: 21px;
font: 7px ui-monospace, SFMono-Regular, Menlo, monospace;
transform: translateX(-50%);
opacity: 0.65;
}
.curtain-sequence > b {
position: relative;
z-index: 2;
display: grid;
place-items: center;
gap: 12px;
font-weight: 700;
}
.curtain-sequence b > em {
/* Sized from the stage, not the viewport, and divided by the letter count so
any label fits. `--n` is the longer of the two labels; see the component. */
font-size: clamp(20px, calc(135cqw / var(--n, 7)), 118px);
font-style: normal;
line-height: 0.75;
letter-spacing: -0.08em;
text-shadow: 0 22px 38px rgba(0, 0, 0, 0.55);
}
.curtain-sequence b > small {
font: 8px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.18em;
color: #8f8f89;
}
.curtain-sequence.running > div > i {
animation: curtain-panel 2.45s var(--delay) cubic-bezier(0.76, 0, 0.24, 1) both;
}
.curtain-sequence.running > b {
z-index: 6;
animation: curtain-title 2.8s ease-in-out both;
}
.curtain-sequence.running::after {
animation: curtain-index 2.8s ease-in-out both;
}
/*
* The card variant: the 298x240 catalogue frame, at that real size and never scaled.
* Most of this stage already tracked its container — the wordmark is a `cqw` clamp and
* the panels are sixths of the width, so both arrive in a card the right size — and what
* did not survive 320px of height becoming 240 is the chrome around them: a 28px
* viewfinder margin, a 54px frame counter, two lines of 8px mono where there is room for
* one, and a drop shadow measured against 118px type. The mechanism is six panels
* crossing the whole box, which is what it already was, so nothing below touches the
* panels themselves.
*/
.curtain-sequence-stage[data-compact='true'] {
/* 240px was the floor this stage was authored never to drop under; in a card it is the
whole frame, so the floor has to go and the height comes from the frame. Nothing to
neutralise for the corners: this stage never set a radius, and the card clips. */
min-height: 0;
height: 100%;
}
/*
* No `touch-action` line here, and none wanted: the panel layer is `pointer-events: none`
* and the only pointer target is the plate, which is a button and not a drag surface. A
* vertical swipe that starts on this card stays the page's — the one thing that must not
* break inside a scrolling grid of cards — and `pan-y` would cost it its pinch-zoom for
* nothing.
*/
/*
* The viewfinder, pulled in from 28px to 10px and stopped 26px short of the bottom edge.
* The pair of 28px insets is 17% of the stage's authored 320px of height and 23% of the
* card's 240, and the box they left was too tight for a 50px wordmark: 'SEQUENCE' comes to
* something near 230px at that size — arithmetic off the face's advances, not a measured
* render — and `curtain-title` takes it to scale 1.18 mid-run, which is ~272px against the
* 242px those insets leave. 10px gives it 278px and the word clears the side rules at its
* widest. The deeper bottom inset is the type strip below: the instruction ends up under
* the rule rather than across it, which is the relationship it has at full size.
*/
.curtain-sequence-stage[data-compact='true'] .curtain-sequence::before {
inset: 10px 10px 26px;
}
/*
* The frame counter, the one thing here that scaled badly: 54px marks the corner of a
* section and takes 18% of the width of a 298px card, a numeral arguing with the title
* rather than dating it. At 28px it is a corner mark again. Left on its authored 118px
* drop, because the numeral now starts at y=6 and lands at y=124 of 240, so the whole
* travel stays inside the viewfinder and the card needs no keyframe of its own.
*/
.curtain-sequence-stage[data-compact='true'] .curtain-sequence::after {
top: 6px;
right: 12px;
font-size: 28px;
}
/*
* The one line of type that stays, off the corner and onto the strip along the bottom
* edge, and lifted from 8px to 10px: it is the only text left in the card, so it is the
* one thing here that has to be legible rather than atmospheric. 25 uppercase mono
* characters at 10px with the authored 0.12em of tracking come to ~180px of the 278px the
* strip has, so the longer of the two strings still sets on one line.
*
* It earns the strip because nothing else here says the plate is a control — the card runs
* the sequence by itself, but the press is the item, and this is the line that turns over
* to 'Sequence running' while the panels are out. Under `prefers-reduced-motion` the loop
* never starts and this is the only thing telling a visitor the card is not a screenshot.
*/
.curtain-sequence-stage[data-compact='true'] .curtain-sequence > span {
inset: auto 0 0 0;
padding: 0 10px 8px;
font-size: 10px;
text-align: center;
}
/*
* 'EDITORIAL TRANSITION' at 8px under a 50px wordmark is a second whisper arguing with the
* one along the bottom edge, and the card's own title and description already say what this
* is. It takes the plate's 12px gap with it, so the wordmark is the whole of `b`.
*/
.curtain-sequence-stage[data-compact='true'] .curtain-sequence b > small {
display: none;
}
/*
* The wordmark, centred in the viewfinder rather than in the frame. A button centres its
* content over its full height, so with the type strip holding the bottom 26px the title
* would sit 8px low inside the rules and the crosshair would cross its caps instead of its
* middle. Same box as the `::before` above, stated rather than inherited, which also takes
* the one piece of this composition that relied on button centring off it. The `z-index` is
* untouched: the running rule still lifts it to 6 and the panels still pass under it.
*
* The transition is for the other end of the run. `curtain-title` is `both`-filled and
* leaves the word at opacity 0 / scale 1.18 / blur 8px, and `.running` comes off 50ms
* later, so the resting wordmark cuts straight back in. Once per press that reads as the
* shutter closing; on a card that presses itself every 3.85s it is a blink. Removing a
* filled animation is a computed-value change from its own last frame, so these three
* properties settle instead of snapping — and a browser that resolves that as a cut lands
* exactly on today's behaviour, which is why this is one declaration and not a second
* keyframe. Safe at the start of a run too: whatever it travels through, it hands over at
* the 0.72 / blur(12px) the keyframe holds until 24% anyway. Compact only — a press at
* full size is still the cut it was authored as.
*/
.curtain-sequence-stage[data-compact='true'] .curtain-sequence > b {
position: absolute;
inset: 10px 10px 26px;
transition:
opacity 240ms ease-out,
transform 240ms ease-out,
filter 240ms ease-out;
}
/*
* The drop shadow under the wordmark, rescaled with the type it belongs to. `0 22px 38px`
* is 0.19em of offset and 0.32em of blur against the 118px the title reaches in a section;
* left alone under a 50px card wordmark the same pixels are 0.44em and 0.76em — a dark
* cloud sitting clear of the letters rather than a shadow under them, and it is at its most
* visible in the half second the title is #111 over the light panels. 9px and 16px are that
* authored ratio at this size.
*/
.curtain-sequence-stage[data-compact='true'] .curtain-sequence b > em {
text-shadow: 0 9px 16px rgba(0, 0, 0, 0.55);
}
/*
* The panel numbers, lifted clear of the type strip. A panel is a sixth of the stage —
* broad in a section, 50px in a card — so the numbers on panels 3 and 4 land in the middle
* of the card, which is exactly where the centred instruction now is, and the instruction
* wins on z-index. 42px puts them in the lower third of the panel with the strip below them
* empty.
*/
.curtain-sequence-stage[data-compact='true'] .curtain-sequence i > small {
bottom: 42px;
}