Animation Updates

This commit is contained in:
Greg Kilmer
2026-07-04 11:28:13 -04:00
parent 33fa312645
commit 29b4a13345
6 changed files with 124 additions and 23 deletions
+13 -10
View File
@@ -127,17 +127,19 @@ export default function AnimatedBackground({
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Dense field tuned for ~40% constellation coverage on any slice. Stars // Dense field tuned for ~40% constellation coverage on any slice. Stars
// live in a sky disk larger than the viewport so rotation never reveals // live in a sky disk large enough that rotation never reveals an empty
// an empty edge. Radius = distance to the far viewport corner. // edge. The disk is centered on the PIVOT (the top-right corner) so the
const cx = width / 2; // whole field rotates around that corner like a star chart turning about
const cy = height / 2; // its corner mount; maxR reaches the far (bottom-left) corner.
const maxR = Math.sqrt(cx * cx + cy * cy) + 40; const cx = width;
const cy = 0;
const maxR = Math.sqrt(cx * cx + height * height) + 40;
// One star per ~4000 px^2 of the disk, capped for perf (~12% anchors). // One star per ~3200 px^2 of the disk, capped for perf (~14% anchors).
const diskArea = Math.PI * maxR * maxR; const diskArea = Math.PI * maxR * maxR;
const count = Math.min(420, Math.floor(diskArea / 4000)); const count = Math.min(520, Math.floor(diskArea / 3200));
stars = Array.from({ length: count }, (_, i) => { stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 8 === 0; // ~12% anchors -> denser constellation backbone const anchor = i % 7 === 0; // ~14% anchors -> dense constellation backbone
// Uniform-ish distribution across the disk via sqrt on radius. // Uniform-ish distribution across the disk via sqrt on radius.
const rr = maxR * Math.sqrt(Math.random()); const rr = maxR * Math.sqrt(Math.random());
const a = Math.random() * Math.PI * 2; const a = Math.random() * Math.PI * 2;
@@ -178,8 +180,9 @@ export default function AnimatedBackground({
angle += ROT_SPEED * dt; // whole sky rotates together angle += ROT_SPEED * dt; // whole sky rotates together
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
const cx = width / 2; // Pivot = top-right corner (matches the disk center used in resize()).
const cy = height / 2; const cx = width;
const cy = 0;
const cosA = Math.cos(angle); const cosA = Math.cos(angle);
const sinA = Math.sin(angle); const sinA = Math.sin(angle);
+12 -9
View File
@@ -94,14 +94,16 @@ export default function ConstellationField({
canvas.height = Math.floor(height * dpr); canvas.height = Math.floor(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const cx = width / 2; // Pivot = top-right corner. The disk is centered there so the field
const cy = height / 2; // rotates around that corner; maxR reaches the far (bottom-left) corner.
const maxR = Math.sqrt(cx * cx + cy * cy) + 40; const cx = width;
// Tuned for ~40% constellation coverage. One star per ~6500 px^2, scaled const cy = 0;
// by density; ~14% anchors for a rich constellation backbone. const maxR = Math.sqrt(cx * cx + height * height) + 40;
// Denser field for richer constellations. One star per ~4500 px^2, scaled
// by density; ~15% anchors for a dense constellation backbone.
const count = Math.min( const count = Math.min(
260, 360,
Math.floor(((Math.PI * maxR * maxR) / 6500) * density), Math.floor(((Math.PI * maxR * maxR) / 4500) * density),
); );
stars = Array.from({ length: count }, (_, i) => { stars = Array.from({ length: count }, (_, i) => {
const anchor = i % 7 === 0; const anchor = i % 7 === 0;
@@ -143,8 +145,9 @@ export default function ConstellationField({
angle += ROT_SPEED * dt; angle += ROT_SPEED * dt;
ctx.clearRect(0, 0, width, height); ctx.clearRect(0, 0, width, height);
const cx = width / 2; // Pivot = top-right corner (matches the disk center used in resize()).
const cy = height / 2; const cx = width;
const cy = 0;
const cosA = Math.cos(angle); const cosA = Math.cos(angle);
const sinA = Math.sin(angle); const sinA = Math.sin(angle);
const project = (rx: number, ry: number): [number, number] => [ const project = (rx: number, ry: number): [number, number] => [
+10 -1
View File
@@ -18,12 +18,21 @@ import { navGroups, siteIdentity } from '@lib/site';
const path = Astro.url.pathname; const path = Astro.url.pathname;
const isActive = (href: string) => const isActive = (href: string) =>
href === '/' ? path === '/' : path === href || path.startsWith(href + '/'); href === '/' ? path === '/' : path === href || path.startsWith(href + '/');
interface Props {
/** True on the Home page. When false, the nav carries the hero-sky transition
* name so the Home hero can morph into / out of it (expand/collapse). */
isHome?: boolean;
}
const { isHome = false } = Astro.props;
// On non-Home pages the NavBar is the morph target for the Home hero.
const heroSkyClass = isHome ? '' : 'vt-hero-sky';
--- ---
<header <header
id="site-nav" id="site-nav"
data-nav data-nav
class="fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300" class:list={['fixed inset-x-0 top-0 z-50 transition-[background,box-shadow,border-color] duration-300', heroSkyClass]}
> >
<div class="border-b border-transparent transition-colors duration-300" data-nav-inner> <div class="border-b border-transparent transition-colors duration-300" data-nav-inner>
<nav <nav
+20 -2
View File
@@ -43,6 +43,10 @@ const {
ogImage, ogImage,
} = Astro.props; } = Astro.props;
// True on the Home page. Used to switch the NavBar's view-transition-name so
// the Home hero can morph into / out of the nav bar (expand/collapse effect).
const isHome = Astro.url.pathname === '/';
const fullTitle = title const fullTitle = title
? `${title} | ${siteIdentity.name}` ? `${title} | ${siteIdentity.name}`
: `${siteIdentity.name} — Developer. Maker. Storyteller.`; : `${siteIdentity.name} — Developer. Maker. Storyteller.`;
@@ -95,14 +99,28 @@ const ogImageUrl = ogImage
import '@/scripts/reveal-init'; import '@/scripts/reveal-init';
</script> </script>
<!-- View Transitions flash-killer. The incoming page is snapshotted with its
`.reveal` elements still at opacity:0 (the observer hasn't fired yet),
which makes content vanish during the morph. This toggles a class on
<html> around the swap so global.css forces reveals visible mid-flight,
then lets the observer take over for scroll reveals on the new page. -->
<script is:inline>
document.addEventListener('astro:before-swap', () => {
document.documentElement.classList.add('vt-transitioning');
});
document.addEventListener('astro:after-swap', () => {
document.documentElement.classList.remove('vt-transitioning');
});
</script>
<ClientRouter /> <ClientRouter />
</head> </head>
<body data-surface={surface}> <body data-surface={surface} data-page={isHome ? 'home' : 'other'}>
<a href="#main" class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-(--color-amber) focus:px-4 focus:py-2 focus:text-(--color-ink)"> <a href="#main" class="sr-only focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:z-[100] focus:rounded-lg focus:bg-(--color-amber) focus:px-4 focus:py-2 focus:text-(--color-ink)">
Skip to content Skip to content
</a> </a>
<NavBar /> <NavBar isHome={isHome} />
<main id="main"> <main id="main">
<slot /> <slot />
+1 -1
View File
@@ -51,7 +51,7 @@ alt: 'Resin crafting in progress',
<BaseLayout> <BaseLayout>
<!-- ===================== HERO ===================== --> <!-- ===================== HERO ===================== -->
<section class="relative flex min-h-[88vh] items-center overflow-hidden pt-16"> <section class="vt-hero-sky relative flex min-h-[88vh] items-center overflow-hidden pt-16">
<AnimatedBackground client:idle transitionName="sky" /> <AnimatedBackground client:idle transitionName="sky" />
<div class="container-prose relative z-10 flex flex-col items-start gap-8 py-20"> <div class="container-prose relative z-10 flex flex-col items-start gap-8 py-20">
+68
View File
@@ -308,3 +308,71 @@
.vt-sky { .vt-sky {
view-transition-name: sky; view-transition-name: sky;
} }
/* Hero expand/collapse morph. The Home hero section carries this name on Home;
* the NavBar carries it on every OTHER page. So leaving Home morphs the tall
* hero down into the nav bar (collapse), and arriving Home expands it back up
* from the nav bar. The custom animation below makes the morph a smooth
* vertical scale + position interpolation rather than the default cross-fade,
* so the starry field reads as growing/shrinking rather than fading. */
.vt-hero-sky {
view-transition-name: hero-sky;
}
::view-transition-group(hero-sky) {
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
::view-transition-old(hero-sky),
::view-transition-new(hero-sky) {
/* A gentle height/scale blend reads as expand/collapse rather than a swap. */
animation-duration: 0.4s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
overflow: clip;
}
/* ---------------------------------------------------------------------------
* View Transitions — smooth cross-page navigation.
*
* Astro's <ClientRouter /> uses the native View Transitions API. Without the
* rules below the browser falls back to a blunt default cross-fade that feels
* harsh, and worse: the NEW page is snapshotted while its `.reveal` elements
* are still at opacity:0 (the scroll-reveal observer hasn't fired yet), so all
* content flashes out during the morph and re-appears after the swap. The two
* fixes here:
*
* 1. `::view-transition-group(root)` gets a short, soft duration + ease so
* the swap is quick and gentle instead of slow and jarring.
* 2. `html:has(...)::view-transition-*` + an `astro:before-swap` class forces
* every `.reveal` to `is-visible` DURING the transition — so the snapshot
* of the incoming page shows its content, eliminating the flash. (The
* reveal-init.ts observer still handles the normal scroll case.)
*
* Reduced-motion users get an instant swap (no animation).
* ------------------------------------------------------------------------- */
@view-transition {
navigation: auto;
}
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.28s;
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
/* During a transition, force all reveal content visible so the incoming page's
* snapshot isn't blank. `.vt-transitioning` is toggled by the script below on
* astro:before-swap and removed on astro:after-swap. */
html.vt-transitioning .reveal {
opacity: 1 !important;
transform: none !important;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-old(root),
::view-transition-new(root),
::view-transition-group(*) {
animation: none !important;
}
}