feat: homepage animations; Arabic as default locale

This commit is contained in:
2026-09-24 00:22:40 +02:00
parent 2ff5cf47cf
commit 1879a206ad
14 changed files with 268 additions and 56 deletions
+9 -1
View File
@@ -40,6 +40,8 @@ export interface AccordionProps {
*/
defaultOpenIndex?: number | null;
className?: string;
/** Extra classes for each question row, e.g. a scroll-driven reveal. */
itemClassName?: string;
}
/**
@@ -75,7 +77,12 @@ function ToggleMark({ open }: { open: boolean }) {
);
}
export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionProps) {
export function Accordion({
items,
defaultOpenIndex = 0,
className,
itemClassName,
}: AccordionProps) {
const baseId = useId();
const initial =
defaultOpenIndex === null ? null : (items[defaultOpenIndex]?.id ?? null);
@@ -97,6 +104,7 @@ export function Accordion({ items, defaultOpenIndex = 0, className }: AccordionP
"border-b border-[var(--border-default)]",
"transition-colors duration-[var(--motion-duration-base)]",
"hover:bg-[var(--surface-muted)]",
itemClassName,
)}
>
<button
+103
View File
@@ -268,6 +268,39 @@
animation-range: entry 0% entry 80%;
}
/* The edge tiles of a row land in from their own side of the line - the
reading start and the reading end - instead of rising, so a row drags
into place edge-first while its middle rises. `translateX` has no
logical keyword, so the RTL block swaps the two names: the drift always
comes off the start edge, whichever script is on screen. */
.sh-reveal--from-start {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end {
animation-name: sh-rise-from-start;
}
/* A beat between the tiles of a row: each item after the first holds at
its hidden state until it is a little deeper in the viewport, so a row
cascades start-to-end instead of landing as one. Only the start of the
range shifts; `.sh-reveal--in` and the edge slides keep their ends. */
.sh-reveal--step-1 {
animation-range-start: entry 12%;
}
.sh-reveal--step-2 {
animation-range-start: entry 24%;
}
/* The hero picture drifting as the page scrolls past it. It is scaled up
before it travels, so the drift can never uncover an edge of the
frame. Chromium-only, like every timeline here: elsewhere the picture
@@ -306,6 +339,49 @@
transform-origin: right center;
}
}
/* THE SAME REVEAL, WITHOUT A TIMELINE. `_page-kit/reveal.ts` tags a
`.sh-reveal` element `.sh-reveal-js` (hidden) in browsers that lack
scroll timelines, then `.sh-reveal-js--in` plays `sh-rise` once it
scrolls into view. Both classes are inert without the helper, so
content is always readable. */
.sh-reveal-js {
opacity: 0;
}
.sh-reveal-js--in {
animation: sh-rise var(--motion-duration-reveal)
var(--motion-ease-entrance) both;
}
/* The edge tiles keep their drift here too: the reader who never sees a
scroll timeline still gets the same from-start / from-end arrival. The
RTL mirrors swap the names, as the Chromium block above does. */
.sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
.sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-start.sh-reveal-js--in {
animation-name: sh-rise-from-end;
}
[dir="rtl"] .sh-reveal--from-end.sh-reveal-js--in {
animation-name: sh-rise-from-start;
}
/* The same beat, expressed in time where there is no timeline. `both`
already answers for the hidden hold during the delay. */
.sh-reveal--step-1.sh-reveal-js--in {
animation-delay: var(--motion-duration-fast);
}
.sh-reveal--step-2.sh-reveal-js--in {
animation-delay: calc(var(--motion-duration-fast) * 2);
}
}
}
@@ -333,6 +409,33 @@
}
}
/* The same reveal turned sideways. `--from-start` is picked up by the RTL
block as the mirror of `--from-end`, so both names exist and the swap is
legible there instead of shipping one keyframe and praying. */
@keyframes sh-rise-from-start {
from {
opacity: 0;
transform: translateX(-20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-rise-from-end {
from {
opacity: 0;
transform: translateX(20px) scale(0.985);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sh-bar-grow {
from {
transform: scaleX(0);
+45
View File
@@ -0,0 +1,45 @@
/* Reveal-on-scroll for browsers without scroll-driven timelines.
----------------------------------------------------------------------------
motion.css handles `.sh-reveal` with `animation-timeline: view()`, which is
Chromium-only; elsewhere every band sits at rest, fully visible. This helper
gives those browsers the same `.sh-rise` reveal: it tags each `.sh-reveal`
element `.sh-reveal-js` (hidden), then flips it to `.sh-reveal-js--in` (the
animation runs) once it scrolls into view.
It is a progressive enhancement and is strictly opt-in per page:
- Chromium already animates via CSS, so this returns early and never
fights the scroll-driven version.
- Reduced-motion visitors are left alone (the CSS never hides them).
- With JS off, no classes are ever added, so content stays visible.
Renders nothing on the server; call from a `useEffect` in the page. */
const SELECTOR = ".sh-reveal";
export function enableReveals(): void {
if (typeof window === "undefined") return;
if (!("IntersectionObserver" in window)) return;
if ("CSS" in window && CSS.supports("animation-timeline: view()")) {
/* Chromium already plays the reveal; nothing to add. */
return;
}
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const element = entry.target;
element.classList.add("sh-reveal-js--in");
element.classList.remove("sh-reveal-js");
observer.unobserve(element);
}
},
{ rootMargin: "0px 0px -8% 0px", threshold: 0.05 },
);
for (const element of document.querySelectorAll<HTMLElement>(SELECTOR)) {
element.classList.add("sh-reveal-js");
observer.observe(element);
}
}