Skip to content

Commit dee17f6

Browse files
committed
@ Banner: title sink, narrower break-out, feather edges, SPA nav fix
- h1 gets a mask-image gradient driven by a --p CSS variable. JS sets it on every scroll frame (smoothstep of scrollY/pinDist), so the title's bottom half progressively fades to transparent as the banner pins behind it. At p=1 the top 30% stays solid, 30%–90% gradient- fades, 90%–100% fully transparent. At p=0 both stops park past 100% so the visible mask area is solid throughout. - Banner break-out trimmed from -3rem to -2rem (mobile -1.5rem to -1rem). Wider than the column-inner text it backs, but with breathing room before the column's outer edge — feels like a plate rather than a full-bleed strip. - Edge feather replaces the inset-shadow vignette / hairline border experiments. Two linear-gradient masks on .cb-track combined via mask-composite: intersect (10px horizontal, 8px vertical falloff). Where the mask is transparent, .cine-banner's solid bg shows through, matching the page bg — invisible seam, no text leak. Linear falloff feels softer than the Gaussian shoulder of an inset box-shadow, no visible boundary line. - bannerEase no longer waits on the .enter animation's `finished` Promise before running init. The wait could strand the banner invisible (opacity: 0 from .enter's initial keyframe) when the animation timeline pauses — happens reliably in headless / hidden tabs and intermittently after SPA nav back from a sketch when view transitions briefly suspend animations. init's first line removes the .enter class which cancels the animation regardless of state, so the wait was always unnecessary. Trade-off: banner skips its fade-in entry; h1 / sub / list still get theirs.
1 parent 8410866 commit dee17f6

2 files changed

Lines changed: 72 additions & 19 deletions

File tree

src/components/CineBanner.astro

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,21 @@ const filterId = `cb-blur-${Math.random().toString(36).slice(2, 8)}`;
212212
position: relative;
213213
width: 100%;
214214
height: 100%;
215+
/* Feathered edges. Two linear-gradient masks combined via
216+
mask-composite: intersect — opaque center where both axes are
217+
opaque, faded at any edge where either axis is transparent.
218+
Where the mask is transparent, .cine-banner's solid bg (matching
219+
the page bg) shows through, so the seam is invisible regardless
220+
of what scrolls behind. Linear falloff feels softer than the
221+
Gaussian shoulder of an inset box-shadow. */
222+
-webkit-mask-image:
223+
linear-gradient(to right, transparent, #000 10px, #000 calc(100% - 10px), transparent),
224+
linear-gradient(to bottom, transparent, #000 8px, #000 calc(100% - 8px), transparent);
225+
-webkit-mask-composite: source-in;
226+
mask-image:
227+
linear-gradient(to right, transparent, #000 10px, #000 calc(100% - 10px), transparent),
228+
linear-gradient(to bottom, transparent, #000 8px, #000 calc(100% - 8px), transparent);
229+
mask-composite: intersect;
215230
}
216231

217232
.cb-media {

src/pages/index.astro

Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,27 @@ const BANNER_H = 120;
170170
pinDist = Math.max(0, natY - pinY);
171171
};
172172

173+
// Cached once at init: the title participates in the same scroll
174+
// curve via a CSS variable that drives a mask-image gradient (see
175+
// h1's CSS rule). Setting it on the element directly keeps the
176+
// var local — no global pollution.
177+
const titleEl = document.querySelector<HTMLElement>('article > header > h1');
178+
173179
const apply = () => {
174180
if (stopped) return;
175181
const s = window.scrollY;
176-
if (pinDist <= 0 || s <= 0 || s >= pinDist) {
182+
let progress = 0;
183+
if (pinDist <= 0 || s <= 0) {
184+
banner.style.transform = '';
185+
} else if (s >= pinDist) {
177186
banner.style.transform = '';
187+
progress = 1;
178188
} else {
179189
const t = s / pinDist;
180-
const p = t * t * (3 - 2 * t);
181-
banner.style.transform = `translate3d(0, ${pinDist * (t - p)}px, 0)`;
190+
progress = t * t * (3 - 2 * t);
191+
banner.style.transform = `translate3d(0, ${pinDist * (t - progress)}px, 0)`;
182192
}
193+
if (titleEl) titleEl.style.setProperty('--p', String(progress));
183194
raf = 0;
184195
};
185196

@@ -215,12 +226,18 @@ const BANNER_H = 120;
215226
};
216227
};
217228

218-
const anim = banner.getAnimations?.()[0];
219-
if (anim && anim.playState !== 'finished') {
220-
anim.finished.then(init).catch(() => {});
221-
} else {
222-
init();
223-
}
229+
// Run init immediately rather than waiting on the .enter
230+
// animation's `finished` Promise. The wait was originally there to
231+
// sidestep animation-fill-mode locking transform after completion,
232+
// but init's first line removes the .enter class which cancels the
233+
// animation regardless of state — same effect, no race. The wait
234+
// could leave the banner invisible if the animation never reaches
235+
// its end frame (paused tab during SPA nav back, view transition
236+
// suspending the timeline, etc.), since opacity: 0 from .enter's
237+
// initial keyframe is retained while finished hasn't resolved.
238+
// Trade-off: banner skips the fade-in entry; h1 / sub / list still
239+
// get theirs from .enter on those elements.
240+
init();
224241
}
225242

226243
attachHeat();
@@ -273,13 +290,13 @@ const BANNER_H = 120;
273290
z-index: 20;
274291
isolation: isolate;
275292
margin-bottom: 2.5em;
276-
/* Break out of the column's 3rem horizontal padding so the banner
277-
reaches the column's outer edges. When pinned, it lifts up
278-
behind the header as a background plate, intentionally wider
279-
than the text it backs. The mask/fade pseudo-elements inherit
280-
this extended width via left/right: 0. */
281-
margin-left: -3rem;
282-
margin-right: -3rem;
293+
/* Break out partway past the column's 3rem horizontal padding
294+
wider than the text it backs (so it reads as a background plate
295+
when it pins behind the header) but with breathing room before
296+
reaching the column's outer edge. The mask/fade pseudo-elements
297+
inherit this width via left/right: 0. */
298+
margin-left: -2rem;
299+
margin-right: -2rem;
283300
}
284301
/* Solid mask above the banner: covers any content that scrolls up
285302
into the strip behind the header. Height 100vh so even at extreme
@@ -324,10 +341,11 @@ const BANNER_H = 120;
324341
article {
325342
--header-offset: calc(var(--stage-h-mobile, 30svh) + 2rem);
326343
}
327-
/* Mobile column padding is 1.5rem, so match the banner break-out. */
344+
/* Mobile column padding is 1.5rem; scale the break-out down
345+
proportionally to keep the same partial-extension feel. */
328346
.banner-wrap {
329-
margin-left: -1.5rem;
330-
margin-right: -1.5rem;
347+
margin-left: -1rem;
348+
margin-right: -1rem;
331349
}
332350
.banner-wrap::before {
333351
height: max(0px, calc(2rem + (var(--header-h, 0px) - var(--banner-h, 0px)) / 2));
@@ -346,6 +364,26 @@ const BANNER_H = 120;
346364
font-size: 2.6rem;
347365
margin: 0;
348366
line-height: 1.2;
367+
/* Title sinks into the banner as it pins. JS writes --p (0 =
368+
banner at natural / scrollY=0, 1 = banner fully pinned) on
369+
every scroll frame; two stops in the mask gradient interpolate
370+
toward more aggressive fade values. At p=1 the top 30% stays
371+
solid, 30%–90% gradient-fades, and 90%–100% is fully
372+
transparent. At p=0 both stops park well past 100% so the
373+
visible 0–100% mask area is solid black throughout. */
374+
--p: 0;
375+
-webkit-mask-image: linear-gradient(
376+
to bottom,
377+
#000,
378+
#000 calc(30% + 70% * (1 - var(--p))),
379+
transparent calc(90% + 110% * (1 - var(--p)))
380+
);
381+
mask-image: linear-gradient(
382+
to bottom,
383+
#000,
384+
#000 calc(30% + 70% * (1 - var(--p))),
385+
transparent calc(90% + 110% * (1 - var(--p)))
386+
);
349387
}
350388
.sub {
351389
font-family: var(--font-mono);

0 commit comments

Comments
 (0)