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:
parent
21718e5e35
commit
5d4de0868f
4 changed files with 32 additions and 7 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue