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
7 changes: 5 additions & 2 deletions docs/02-prd/03-solution.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,11 @@ Motion is a grammar, not garnish:
## Edge cases

- **Reduced motion**: the boot sequence is absent (§Boot sequence); canvas
instruments render their static state, loops replaced by posters. Every state
readable with zero motion.
instruments render their static state, loops replaced by posters; the marquee rows
become plain scrollers, since a stopped drift would strand every entry past the edge
out of reach. Reveals and the reticle fade in place rather than travelling: they still
animate, because the server cannot know the preference and the animation is what clears
the rendered start state. Every state readable with zero motion.
- **Mobile**: the instrument panel recomposes (edge-density becomes top/bottom
strips); grab-reel becomes native touch scroll; hover-revealed metadata must have
a visible-by-default equivalent.
Expand Down
6 changes: 3 additions & 3 deletions src/components/atoms/intro.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { Decode } from '@/components/atoms/decode'
import { cn, cva, type VariantProps } from '@/utils/theme'

const styles = {
root: cva('relative flex max-w-100 flex-col items-center justify-center gap-4 text-center'),
subtitle: cva('text-muted-foreground text-e4 font-expressive uppercase'),
root: cva('relative flex max-w-100 flex-col items-center justify-center gap-2 text-center'),
subtitle: cva('text-foreground text-e4 font-expressive mb-2 uppercase'),
title: cva('text-h1 font-heading whitespace-pre-line uppercase'),
desc: cva('text-muted-foreground text-sm whitespace-pre-line'),
desc: cva('text-foreground text-sm whitespace-pre-line'),
cta: cva('flex flex-col items-center gap-2')
}

Expand Down
15 changes: 11 additions & 4 deletions src/components/atoms/marquee.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { forwardRef, type HTMLAttributes } from 'react'
import { cn, cva, type VariantProps } from '@/utils/theme'

const styles = {
root: cva('w-full overflow-hidden'),
track: cva(['animate-marquee flex w-max', 'hover:paused', 'motion-reduce:paused'], {
// the drift is the only thing bringing offscreen entries into view, so under reduced
// motion the row hands that job to the reader as a plain scroller
root: cva([
'w-full overflow-hidden',
'motion-reduce:overflow-x-auto motion-reduce:overscroll-x-contain'
]),
track: cva(['animate-marquee flex w-max', 'hover:paused', 'motion-reduce:animate-none'], {
variants: {
direction: {
left: '',
Expand All @@ -14,7 +19,9 @@ const styles = {
direction: 'left'
}
}),
group: cva('flex shrink-0 items-stretch gap-8 pr-8')
group: cva('flex shrink-0 items-stretch gap-8 pr-8'),
// the duplicate exists only to hide the loop's seam; scrolling it would just repeat
duplicate: cva('motion-reduce:hidden')
}

type MarqueeRef = HTMLDivElement
Expand All @@ -29,7 +36,7 @@ const Marquee = forwardRef<MarqueeRef, MarqueeProps>((props, ref) => {
<div ref={ref} className={cn(styles.root({ className }))} {...rest}>
<div className={cn(styles.track({ direction }))}>
<div className={cn(styles.group())}>{children}</div>
<div className={cn(styles.group())} aria-hidden>
<div className={cn(styles.group(), styles.duplicate())} aria-hidden>
{children}
</div>
</div>
Expand Down
6 changes: 4 additions & 2 deletions src/components/atoms/reticle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ const styles = {
],
{
variants: {
// the outer marks boot in from off-centre, so under reduced motion they fade in
// place like the middle one rather than travelling their own height
position: {
1: 'animate-boot-down',
1: 'animate-boot-down motion-reduce:animate-fade-in',
2: 'animate-fade-in',
3: 'animate-boot-up'
3: 'animate-boot-up motion-reduce:animate-fade-in'
}
}
}
Expand Down
16 changes: 5 additions & 11 deletions src/components/atoms/reveal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { forwardRef, type ReactNode } from 'react'
import { motion, useReducedMotion } from 'motion/react'
import { Slot } from 'radix-ui'
import { motionReveal, revealItem } from '@/constants/motion'
import { motionReveal, revealItem, revealItemReduced } from '@/constants/motion'
import { cn, cva } from '@/utils/theme'

const styles = {
Expand All @@ -26,7 +26,9 @@ type RevealProps = {

/**
* Fades and lifts its content in the first time it scrolls into view. Each Reveal
* triggers on its own intersection.
* triggers on its own intersection. Under reduced motion the content fades without
* travelling; it always animates rather than rendering statically, because the server
* cannot know the preference and only the animation clears the SSR-rendered start state.
*/
const Reveal = forwardRef<RevealRef, RevealProps>((props, ref) => {
// props
Expand All @@ -40,20 +42,12 @@ const Reveal = forwardRef<RevealRef, RevealProps>((props, ref) => {
const viewport = { once: true, amount: rail ? motionReveal.railAmount : amount }

// jsx
if (reduced) {
const Static = asChild ? Slot.Root : 'div'
return (
<Static ref={ref} className={cn(styles.root({ className }))}>
{children}
</Static>
)
}
return (
<Comp
ref={ref}
className={cn(styles.root({ className }))}
initial="hidden"
variants={revealItem}
variants={reduced ? revealItemReduced : revealItem}
viewport={viewport}
whileInView="shown"
>
Expand Down
2 changes: 1 addition & 1 deletion src/components/molecules/record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const styles = {
name: cva('text-md'),
span: cva('text-e4 font-expressive text-muted-foreground uppercase'),
location: cva('text-e4 font-expressive text-muted-foreground uppercase'),
desc: cva('text-muted-foreground text-sm whitespace-pre-line'),
desc: cva('text-foreground text-sm whitespace-pre-line'),
skills: cva('flex flex-wrap gap-2')
}

Expand Down
4 changes: 2 additions & 2 deletions src/components/molecules/testimonial.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const styles = {
root: cva('flex flex-col'),

quoteCell: cva('grow border-b p-4'),
quote: cva('text-muted-foreground line-clamp-9 text-sm whitespace-pre-line italic'),
quote: cva('text-foreground line-clamp-9 text-sm whitespace-pre-line'),

footerCell: cva('flex items-center gap-4 px-4 py-4'),
photo: cva('size-12 rounded-sm object-cover'),
Expand Down Expand Up @@ -46,7 +46,7 @@ const Testimonial = forwardRef<TestimonialRef, TestimonialProps>((props, ref) =>
return (
<Card ref={ref} className={cn(styles.root({ className }))} {...rest}>
<div className={cn(styles.quoteCell())}>
<blockquote className={cn(styles.quote())}>&ldquo;{basic.desc}&rdquo;</blockquote>
<blockquote className={cn(styles.quote())}>{basic.desc}</blockquote>
</div>
<div className={cn(styles.footerCell())}>
{photo ? (
Expand Down
41 changes: 35 additions & 6 deletions src/constants/motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,21 @@ const motionReveal = {
railAmount: 0.01
} as const

/**
* The resting state a `Reveal` starts from, shared by both variants below. It is rendered
* on the server, where the reduced-motion preference is unknowable, so both variants must
* open from it: a client that hydrated a different `hidden` state would mismatch, and React
* does not patch up inline-style mismatches, stranding the element at `opacity: 0`.
*/
const revealHidden = {
opacity: 0,
y: motionReveal.distance,
filter: `blur(${motionReveal.blur}px)`
} as const

/** The `Reveal` variant: the fade + rise + de-blur an element runs when it enters view. */
const revealItem: Variants = {
hidden: {
opacity: 0,
y: motionReveal.distance,
filter: `blur(${motionReveal.blur}px)`
},
hidden: revealHidden,
shown: {
opacity: 1,
y: 0,
Expand All @@ -45,4 +53,25 @@ const revealItem: Variants = {
}
}

export { motionDurations, motionEasings, motionReveal, revealItem }
/**
* The reduced-motion `Reveal` variant: the rise and de-blur snap to their resting values
* while the opacity still fades, so the element arrives without travelling. Opacity alone
* carries no vestibular cost, and the element must still animate to `shown`: it is the only
* thing that clears the server-rendered `hidden` style.
*/
const revealItemReduced: Variants = {
hidden: revealHidden,
shown: {
opacity: 1,
y: 0,
filter: 'blur(0px)',
transition: {
duration: motionDurations.base,
ease: motionEasings.out,
y: { duration: 0 },
filter: { duration: 0 }
}
}
}

export { motionDurations, motionEasings, motionReveal, revealItem, revealItemReduced }
17 changes: 17 additions & 0 deletions src/themes/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,20 @@
opacity: 0;
}
}

/* reduced motion */

/* Vaul drives the drawer panel from its own injected stylesheet, so `motion-reduce:`
utilities cannot reach it. Swap its slide keyframes for a fade: the panel still
announces its arrival, without travelling the height of the viewport. */
@media (prefers-reduced-motion: reduce) {
[data-vaul-drawer][data-state='open'] {
animation-name: fade-in !important;
transition: none !important;
}

[data-vaul-drawer][data-state='closed'] {
animation-name: fade-out !important;
transition: none !important;
}
}
Loading