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
61 changes: 59 additions & 2 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,7 @@ html {
}

/* The navbar is sticky and 4rem tall, so an anchor target would otherwise land
underneath it. The extra rem covers the 1rem a section still travels while its
mount transition settles.
underneath it. The extra 2rem is air between the two.

Not scoped to :target. A same-page hash link scrolls inside the same tick that
pushState sets the hash, before :target has been restyled, so the margin has to
Expand All @@ -62,3 +61,61 @@ section[id] {
scroll-behavior: auto;
}
}

/* Motion. Plain CSS on purpose: the server HTML is visible as sent, and a
browser without scroll timelines (Firefox, for now) shows the page still
instead of hiding it. See issue #41. */
@keyframes rise {
from {
opacity: 0;
transform: translateY(0.75rem) scale(0.985);
}
}

/* The hero's version also comes into focus. Kept off the scroll reveal: a
blur on a whole section repaints it on every scroll frame. */
@keyframes settle {
from {
opacity: 0;
transform: translateY(1rem) scale(0.97);
filter: blur(6px);
}
}

@media (prefers-reduced-motion: no-preference) {
/* Once, on load: the hero's headline, paragraph and buttons in turn. The
curve overshoots a little, so they land instead of stopping. Written out
because the design package has ease-out and ease-in-out and neither
settles, and this file declares no properties of its own. */
.rise > * {
animation: settle calc(var(--wl-duration-slow) * 2) cubic-bezier(0.34, 1.4, 0.64, 1) both;
}
.rise > :nth-child(2) {
animation-delay: calc(var(--wl-duration-base) / 2);
}
.rise > :nth-child(3) {
animation-delay: var(--wl-duration-base);
}

@supports (animation-timeline: view()) {
/* Driven by scroll position, so there is no duration to set. The range is
a length and not a percentage: a percentage of a tall section leaves it
half faded for most of a screen. */
.reveal,
.reveal-each > * {
animation: rise var(--wl-ease-out) both;
animation-timeline: view();
animation-range: entry 0% entry 10rem;
transform-origin: 50% 0;
}

/* Cards in one row cross the viewport edge together, so the later ones
start a little further in. */
.reveal-each > :nth-child(2) {
animation-range: entry 3rem entry 13rem;
}
.reveal-each > :nth-child(3) {
animation-range: entry 6rem entry 16rem;
}
}
}
4 changes: 2 additions & 2 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ export default async function Home() {
{/* Wider than everything below it, and deliberately so: this is the only
two-column section on the page, and the graph needs the second half. */}
<SectionFadeIn className="pt-16 md:pt-24 px-6 max-w-6xl mx-auto w-full grid lg:grid-cols-2 gap-10 lg:gap-12 items-center">
<div className="flex flex-col items-center text-center lg:items-start lg:text-left">
<div className="rise flex flex-col items-center text-center lg:items-start lg:text-left">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-display text-foreground mb-6 leading-tight tracking-tight text-balance">
Reliable infrastructure for the agentic era<span className="text-brand">.</span>
</h1>
Expand Down Expand Up @@ -167,7 +167,7 @@ export default async function Home() {

{featured.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
<div className="reveal-each grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
{featured.map((product) => (
<Card
key={product.name}
Expand Down
2 changes: 1 addition & 1 deletion components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const Card: React.FC<CardProps> = ({
</div>
{linkHref && (
<svg
className="w-5 h-5 shrink-0 text-muted-foreground group-hover:text-brand transition-colors"
className="w-5 h-5 shrink-0 text-muted-foreground transition group-hover:text-brand group-hover:translate-x-0.5 group-hover:-translate-y-0.5 motion-reduce:transform-none"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
Expand Down
30 changes: 9 additions & 21 deletions components/SectionFadeIn.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,16 @@
'use client';

import React, { useEffect, useState } from 'react';
import React from 'react';

interface SectionFadeInProps {
children: React.ReactNode;
className?: string;
id?: string;
}

export const SectionFadeIn: React.FC<SectionFadeInProps> = ({ children, className = '', id }) => {
const [mounted, setMounted] = useState(false);

useEffect(() => {
setMounted(true);
}, []);

return (
<section
id={id}
className={`transition-all duration-1000 ease-out transform motion-reduce:transition-none ${
mounted ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'
} ${className}`}
>
{children}
</section>
);
};
// A server component on purpose. The reveal is the `.reveal` rule in
// globals.css, so the section is visible in the HTML as sent and no script has
// to run before anyone can read it. See issue #41.
export const SectionFadeIn: React.FC<SectionFadeInProps> = ({ children, className = '', id }) => (
<section id={id} className={`reveal ${className}`}>
{children}
</section>
);
35 changes: 35 additions & 0 deletions lib/motion.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';

const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
const section = read('../components/SectionFadeIn.tsx');
const css = read('../app/globals.css');
const pkg = JSON.parse(read('../package.json'));

// The section wrapper used to render opacity-0 on the server and flip it in a
// useEffect, so the page was blank until hydration and stayed blank with
// JavaScript off. See issue #41.
test('a section is visible in the HTML the server sends', () => {
assert.doesNotMatch(section, /opacity-0/, 'a section starts hidden again');
assert.doesNotMatch(section, /use client|useEffect|useState/, 'the reveal depends on script again');
});

test('the reveal only runs where it is supported and wanted', () => {
// Outside these two gates a section has to be plain and visible: that is the
// whole fallback for Firefox and for anyone who asked for reduced motion.
const gated = css.match(
/@media \(prefers-reduced-motion: no-preference\) \{[\s\S]*@supports \(animation-timeline: view\(\)\) \{[\s\S]*\.reveal\b/
);
assert.ok(gated, '.reveal is not behind both the motion preference and the @supports check');
assert.equal(css.split('animation-timeline: view();').length - 1, 1, 'a scroll timeline is set outside the gate');
});

test('timed motion uses the design package durations', () => {
const timed = css.match(/animation:[^;]*\d+m?s[^;]*;/g) ?? [];
assert.deepEqual(timed, [], 'an animation hardcodes a duration instead of a --wl-duration token');
});

test('framer-motion stays out until something imports it', () => {
assert.equal(pkg.dependencies['framer-motion'], undefined);
});
25 changes: 0 additions & 25 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
"@mdx-js/react": "^3.1.0",
"@tailwindcss/typography": "^0.5.16",
"@weekndlabs/design": "^0.5.0",
"framer-motion": "^11.11.17",
"gray-matter": "^4.0.3",
"lucide-react": "^0.461.0",
"next": "14.2.16",
Expand Down
Loading