Split card shell and copy opacity in the project stack

The incoming card was translucent while it rose over the card it
covers, so both sets of copy were legible at once on photo-led cards.
The shell (photo and scrim) now goes opaque as soon as the card starts
moving, while the copy fades in over the whole arrival.
This commit is contained in:
David 2026-11-03 17:52:57 +02:00
parent 21718e5e35
commit 5d4de0868f
4 changed files with 32 additions and 7 deletions

View file

@ -43,7 +43,9 @@ function CardFace({ p, media }: { p: Project; media: ReactNode }) {
{p.location}
</span>
</div>
<div>
{/* The copy fades in on its own, so the card underneath is never
legible through the one landing on it. */}
<div style={{ opacity: 'var(--copy-opacity, 1)' }}>
<h3 className="display text-[clamp(1.5rem,3vw,2.5rem)] font-normal leading-[1.05] text-bone">
{p.name}
</h3>

View file

@ -46,7 +46,10 @@ export function useStackScrub(
const s = cardState(i, t)
card.style.transform = `translate3d(0, ${s.yPercent}%, 0) rotate(${s.rotate}deg) scale(${s.scale})`
card.style.opacity = String(s.opacity)
if (s.opacity > 0) active = i
// The copy fades separately from the shell, so the incoming card does
// not sit translucent over the card it is covering.
card.style.setProperty('--copy-opacity', String(s.contentOpacity))
if (s.contentOpacity > 0) active = i
})
if (active !== activeRef.current) {

View file

@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { ARRIVE, RECEDE, STEP, cardState, clamp01, power1InOut, power2Out, timelineTotal } from './stack.ts'
import { ARRIVE, RECEDE, SHELL, STEP, cardState, clamp01, power1InOut, power2Out, timelineTotal } from './stack.ts'
test('clamp01 pins values to the unit range', () => {
assert.equal(clamp01(-3), 0)
@ -32,6 +32,7 @@ test('the first card starts off-stage', () => {
const s = cardState(0, 0)
assert.equal(s.yPercent, 118)
assert.equal(s.opacity, 0)
assert.equal(s.contentOpacity, 0)
assert.equal(s.scale, 0.93)
assert.equal(s.rotate, -4)
})
@ -40,6 +41,7 @@ test('the first card has landed after one arrival unit', () => {
const s = cardState(0, ARRIVE)
assert.equal(s.yPercent, 0)
assert.equal(s.opacity, 1)
assert.equal(s.contentOpacity, 1)
assert.equal(s.scale, 1)
assert.equal(s.rotate, 0)
})
@ -48,6 +50,7 @@ test('a covered card eases back but stays opaque', () => {
const s = cardState(0, ARRIVE + RECEDE)
assert.equal(s.yPercent, -7)
assert.equal(s.opacity, 1)
assert.equal(s.contentOpacity, 1)
assert.equal(s.scale, 0.92)
})
@ -59,7 +62,15 @@ test('a card that has not arrived stays hidden', () => {
test('the next card arrives while the previous is still on screen', () => {
assert.equal(cardState(0, ARRIVE).opacity, 1)
assert.ok(cardState(1, ARRIVE).opacity > 0)
assert.ok(cardState(1, ARRIVE).contentOpacity > 0)
})
test('the incoming card is opaque before it reaches the card it covers', () => {
// The shell must be solid while the card is still rising, or the copy
// underneath shows through it.
assert.ok(cardState(1, STEP + SHELL).opacity > 0.999)
assert.ok(cardState(1, STEP + SHELL).contentOpacity < 1)
assert.ok(cardState(1, STEP).opacity < 1)
})
test('the last card is fully landed at the end of the timeline', () => {

View file

@ -11,6 +11,8 @@ export const ARRIVE = 1
export const RECEDE = 0.7
/** Gap between one card's arrival and the next card's arrival. */
export const STEP = 0.95
/** Timeline units the card shell takes to become fully opaque. */
export const SHELL = 0.2
export function clamp01(n: number): number {
return n < 0 ? 0 : n > 1 ? 1 : n
@ -35,6 +37,7 @@ export function timelineTotal(count: number): number {
export type CardState = {
yPercent: number
opacity: number
contentOpacity: number
scale: number
rotate: number
}
@ -55,9 +58,15 @@ export function cardState(index: number, t: number): CardState {
return {
yPercent: 118 * (1 - u) - 7 * v,
// The recede only shifts and shrinks the card — it must stay fully opaque.
// A translucent card would let the one behind it ghost through the copy.
opacity: u,
// The shell — the photograph and its scrim — goes opaque quickly, while
// the card is still below the frame. Fading it slowly would leave the
// incoming card translucent over the card it covers, and both sets of
// copy would be legible at the same time.
opacity: clamp01((t - arriveStart) / SHELL),
// The copy fades in over the whole arrival.
contentOpacity: u,
// The recede only shifts and shrinks the card — it must stay fully
// opaque. A translucent card lets the one behind ghost through it.
scale: 0.93 + 0.07 * u - 0.08 * v,
rotate: noNegativeZero(tilt * (1 - u)),
}