Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions src/components/HeroStrands.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
---
/*
* A slow, luminous ribbon field behind the hero.
*
* Inspired by React Bits' "Strands", but written from scratch on a 2D canvas
* rather than pulling in ogl and a WebGL shader. That library is ~25kB gzipped;
* this is a couple of hundred bytes, on a page whose entire JS budget is about
* 2kB. The look is the point, not the implementation.
*
* Deliberately restrained: brand blues only, low alpha, and slow enough that it
* reads as ambient rather than as something demanding attention.
*/
---

<canvas
data-hero-strands
aria-hidden="true"
class="pointer-events-none absolute inset-0 size-full opacity-70 dark:opacity-100"
></canvas>

<script>
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');

// One per hero; a page may have more than one instance.
for (const canvas of document.querySelectorAll<HTMLCanvasElement>('[data-hero-strands]')) {
const ctx = canvas.getContext('2d');

if (ctx) {
// Brand ramp, lightest to deepest. Kept in sync with --color-brand-* by eye;
// exact values matter less than staying in the blue family.
const RIBBONS = [
{ colour: '147, 197, 253', phase: 0.0, amp: 0.10, freq: 1.1, width: 90, y: 0.46 },
{ colour: '78, 163, 252', phase: 2.1, amp: 0.13, freq: 0.8, width: 70, y: 0.52 },
{ colour: '20, 126, 251', phase: 4.2, amp: 0.08, freq: 1.4, width: 55, y: 0.58 },
];

let width = 0;
let height = 0;
let frame = 0;
let running = false;

const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};

const draw = (t: number) => {
ctx.clearRect(0, 0, width, height);
// Additive blending is what gives the strands their glow where they cross.
ctx.globalCompositeOperation = 'lighter';

for (const ribbon of RIBBONS) {
const gradient = ctx.createLinearGradient(0, 0, width, 0);
gradient.addColorStop(0, `rgba(${ribbon.colour}, 0)`);
gradient.addColorStop(0.5, `rgba(${ribbon.colour}, 0.22)`);
gradient.addColorStop(1, `rgba(${ribbon.colour}, 0)`);

ctx.strokeStyle = gradient;
ctx.lineWidth = ribbon.width;
ctx.lineCap = 'round';
ctx.filter = 'blur(28px)';

ctx.beginPath();
for (let x = 0; x <= width; x += 12) {
const p = x / width;
const y =
height * ribbon.y +
Math.sin(p * Math.PI * 2 * ribbon.freq + t + ribbon.phase) * height * ribbon.amp +
Math.sin(p * Math.PI * 4 + t * 0.6 + ribbon.phase) * height * ribbon.amp * 0.3;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
}

ctx.filter = 'none';
ctx.globalCompositeOperation = 'source-over';
};

const loop = () => {
if (!running) return;
frame += 1;
draw(frame * 0.004);
requestAnimationFrame(loop);
};

const start = () => {
if (running || reduceMotion.matches) return;
running = true;
requestAnimationFrame(loop);
};
const stop = () => {
running = false;
};

resize();
draw(0); // a still frame, so it is never blank

// Only animate while it is actually on screen and the tab is focused.
new IntersectionObserver((entries) => {
for (const entry of entries) (entry.isIntersecting ? start : stop)();
}).observe(canvas);

document.addEventListener('visibilitychange', () =>
document.hidden ? stop() : start()
);

window.addEventListener('resize', () => {
resize();
draw(frame * 0.004);
});

// Honour a change of preference without a reload.
reduceMotion.addEventListener('change', () => {
if (reduceMotion.matches) {
stop();
draw(0);
} else {
start();
}
});
}
}
</script>
8 changes: 6 additions & 2 deletions src/components/PageHeader.astro
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
---
import HeroStrands from './HeroStrands.astro';

interface Props {
title: string;
lead?: string;
}
const { title, lead } = Astro.props;
---

<div class="border-b border-line bg-raised">
<div class="mx-auto max-w-5xl px-5 py-12 sm:py-16">
<div class="relative overflow-hidden border-b border-line bg-raised">
<HeroStrands />
{/* Above the canvas, which is absolutely positioned across this container. */}
<div class="relative z-10 mx-auto max-w-5xl px-5 py-12 sm:py-16">
<h1 class="text-4xl font-extrabold tracking-tight text-strong sm:text-5xl">{title}</h1>
{lead && <p class="mt-4 max-w-2xl text-lg text-muted">{lead}</p>}
<slot />
Expand Down
84 changes: 84 additions & 0 deletions src/components/ShimmerBorder.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
/*
* A shimmer that travels around the edge of its parent.
*
* A conic gradient is rotated behind the card and masked down to just the
* border ring, so a single highlight runs continuously around the whole
* perimeter -- corners included.
*
* This is not React Bits' "Star Border", which slides two radial gradients
* along the top and bottom edges. That reads as two streaks passing by rather
* than one shimmer going around, which is the wrong feel for a card.
*
* Drop it inside a `position: relative` element. Decorative, so aria-hidden and
* no pointer events. It masks itself rather than clipping the parent, so
* anything overhanging the card -- a badge, say -- is unaffected.
*/
interface Props {
/** One full lap. */
speed?: string;
/** Ring thickness. */
thickness?: string;
}
const { speed = '5s', thickness = '1.5px' } = Astro.props;
---

<span
class="shimmer-border"
aria-hidden="true"
style={`--shimmer-speed:${speed}; --shimmer-thickness:${thickness}`}
></span>

<style>
/* Registered so the angle can be animated; a bare custom property cannot be
interpolated. Without @property the ring simply sits still, which is a
fine degradation. */
@property --shimmer-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}

.shimmer-border {
position: absolute;
inset: 0;
border-radius: inherit;
padding: var(--shimmer-thickness);
pointer-events: none;

background: conic-gradient(
from var(--shimmer-angle),
transparent 0deg,
transparent 300deg,
color-mix(in oklab, var(--color-accent) 85%, white) 340deg,
transparent 360deg
);

/* Keep only the ring: paint the padding box, punch out the content box. */
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
mask-composite: exclude;

animation: shimmer-spin var(--shimmer-speed) linear infinite;
}

@keyframes shimmer-spin {
to {
--shimmer-angle: 360deg;
}
}

/* Hold it still rather than letting the global reduced-motion rule squash the
duration to nothing and strobe it. */
@media (prefers-reduced-motion: reduce) {
.shimmer-border {
animation: none;
opacity: 0.5;
}
}
</style>
6 changes: 5 additions & 1 deletion src/pages/404.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import BaseLayout from '@layouts/BaseLayout.astro';
import HeroStrands from '@components/HeroStrands.astro';
import { LINKS } from '@data/site';

// NOTE: this is the org site's 404, so GitHub Pages serves it for any
Expand All @@ -13,7 +14,9 @@ import { LINKS } from '@data/site';
path="/404"
noindex
>
<div class="mx-auto flex max-w-2xl flex-col items-center px-5 py-24 text-center">
<div class="relative overflow-hidden">
<HeroStrands />
<div class="relative z-10 mx-auto flex max-w-2xl flex-col items-center px-5 py-24 text-center">
<p class="text-7xl font-extrabold tracking-tight text-accent">404</p>
<h1 class="mt-4 text-3xl font-extrabold tracking-tight text-strong sm:text-4xl">
We couldn't find that page.
Expand Down Expand Up @@ -51,4 +54,5 @@ import { LINKS } from '@data/site';
>.
</p>
</div>
</div>
</BaseLayout>
3 changes: 3 additions & 0 deletions src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { Image } from 'astro:assets';
import BaseLayout from '@layouts/BaseLayout.astro';
import StoreButton from '@components/StoreButton.astro';
import HeroStrands from '@components/HeroStrands.astro';
import { SITE, LINKS } from '@data/site';
import { STORES } from '@data/stores';
import devices from '@assets/devices.png';
Expand All @@ -14,6 +15,8 @@ import devices from '@assets/devices.png';
path="/"
>
<section class="relative overflow-hidden">
<HeroStrands />

{/* Ambient brand wash -- the modern descendant of the old #bg-circle. */}
<div
aria-hidden="true"
Expand Down
6 changes: 5 additions & 1 deletion src/pages/install/privateAPI/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// than port content that is wrong, this keeps the URL alive and points at the
// maintained docs. It is noindex so it stops competing with them in search.
import BaseLayout from '@layouts/BaseLayout.astro';
import HeroStrands from '@components/HeroStrands.astro';
import { LINKS } from '@data/site';

const docsUrl = `${LINKS.docs}/private-api/installation`;
Expand All @@ -19,7 +20,9 @@ const docsUrl = `${LINKS.docs}/private-api/installation`;
path="/install/privateAPI"
noindex
>
<div class="mx-auto max-w-2xl px-5 py-24 text-center">
<div class="relative overflow-hidden">
<HeroStrands />
<div class="relative z-10 mx-auto max-w-2xl px-5 py-24 text-center">
<h1 class="text-3xl font-extrabold tracking-tight text-strong sm:text-4xl">
Private API Installation
</h1>
Expand All @@ -45,4 +48,5 @@ const docsUrl = `${LINKS.docs}/private-api/installation`;
>
</div>
</div>
</div>
</BaseLayout>
4 changes: 3 additions & 1 deletion src/pages/pricing/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import BaseLayout from '@layouts/BaseLayout.astro';
import PageHeader from '@components/PageHeader.astro';
import MacOSPicker from '@components/MacOSPicker.astro';
import ShimmerBorder from '@components/ShimmerBorder.astro';
import { PLANS, PRO_FEATURES, FREE_FEATURES, MACOS_RELEASES } from '@data/pricing';
import { LINKS } from '@data/site';

Expand Down Expand Up @@ -47,8 +48,9 @@ const newestMacOS = MACOS_RELEASES.reduce((a, b) => (b.version > a.version ? b :
plan.featured ? 'border-accent shadow-lg' : 'border-line',
]}
>
{plan.featured && <ShimmerBorder />}
{plan.featured && (
<span class="absolute -top-3 left-6 rounded-full bg-accent-solid px-3 py-1 text-xs font-semibold text-white">
<span class="absolute -top-3 left-6 z-10 rounded-full bg-accent-solid px-3 py-1 text-xs font-semibold text-white">
Best value
</span>
)}
Expand Down
Loading