Accessible motion on a dark portfolio site
Practical notes on contrast, reduced motion, focus order, and animation timing when the brand is dark, editorial, and motion-forward.
Dark portfolios fail accessibility in predictable ways: gray-on-gray text, glow mistaken for focus rings, motion that never stops, and keyboard traps inside fancy sections. Motion-forward brands compound the problem because animation is part of the identity—turning it off cannot mean turning the site off.
These are practices I apply on satanilabs.com and client marketing builds where the aesthetic is deliberately dark and cinematic.
Contrast is a system, not a one-off check
Dark UI needs more than WCAG spot checks on body copy. Elevation layers (surface, surface-elevated, border-subtle) each need paired text tokens. Accent colors used for links must pass on every background they touch, including translucent overlays on photography or canvas.
I define tokens in CSS variables and lint contrast pairs in Storybook or Figma—not by eyballing in DevTools once. Muted text for metadata (text-3) still has a floor; captions are not an excuse for #666 on #111.
Photography and video heroes on dark sites often need scrims—semi-opaque overlays—not vignettes alone. The scrim token should be tested with white text and accent links together. If the brand uses colored glows behind headings, verify contrast on both the glow peak and the surrounding surface.
Typography and readability at night
Dark mode is not only background color. Line length, line height, and weight matter more when users read in dim rooms. I keep body measure around 65–75 characters and bump line-height slightly versus light themes. All-caps labels with wide letter-spacing look premium but hurt dyslexic readers; I limit uppercase to short labels and never for paragraphs.
Motion with an off-ramp
prefers-reduced-motion: reduce should disable translation and large opacity sweeps, not hide content. I use a media query at the root to set --motion-duration: 0ms and --motion-distance: 0, then let components read those variables.
@media (prefers-reduced-motion: reduce) {
:root {
--motion-duration: 0ms;
--motion-stagger: 0ms;
}
}
Parallax and auto-playing loops stop entirely. Essential state changes—accordion expand, toast enter—can keep instant opacity toggles.
Staggered reveals are the usual offender. I cap stagger delay per list (for example 40 ms per item, max 240 ms total) so a twelve-card grid does not animate for two seconds. Users who allow motion still deserve snappy UI.
export function Reveal({ delay = 0, children }: Props) {
const reduce = useReducedMotion();
return (
<motion.div
initial={reduce ? false : { opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: reduce ? 0 : 0.4, delay: reduce ? 0 : delay * 0.04 }}
viewport={{ once: true, margin: "-10% 0px" }}
>
{children}
</motion.div>
);
}
Respect vestibular sensitivity
Large vertical movement triggers vestibular symptoms for some users. I avoid scroll-jacking and horizontal drift on the main reading axis. Canvas backdrops that drift slowly get paused under reduced motion, replaced by a static frame or a subtle CSS gradient.
Focus visible beats focus pretty
Custom focus rings must exceed browser defaults in visibility, not just style. I use :focus-visible outlines with offset on interactive elements inside dark cards. Never outline: none without a replacement.
When motion reveals content, focus order must follow DOM order, not visual stagger. Delayed animations should not insert focusable elements before they are visible—tabbing into invisible links is disorienting.
On dark cards, :focus-visible rings often need two tones—a light outer ring and a dark inner ring—so they remain visible on both #0a0a0a backgrounds and saturated accent buttons. Test focus on primary buttons, text links, and ghost buttons separately; one ring style rarely fits all.
Color alone never carries meaning
Status chips, chart legends, and tags must pair color with text or icons. Dark themes exaggerate “red means bad” assumptions; deuteranopia-friendly palettes still fail if you only change hue slightly. I use shape and label together: “Draft,” “Shipped,” not red vs green dots alone.
Keyboard paths through chapters
Long scrolling home pages split into chapters. Each chapter’s interactive controls—links, buttons, form fields—should be reachable without a mouse. Skip links to main content and to contact still matter on single-page layouts.
Carousels, if used, need roving tabindex and announced slide changes. I prefer static grids for portfolios; carousels are rarely worth the a11y cost on studio sites.
Fixed navigation on immersive pages should not cover focused elements. When a user tabs to a footer link, scroll-padding-top on html accounts for the nav height so focus rings are not clipped under a blurred header bar.
Writing and editorial surfaces
Blog and insight pages on a dark portfolio often switch to a calmer editorial theme. Motion there should be minimal—maybe a single fade on the article body—so reading comfort matches Medium or technical docs, not the cinematic home page. Consistency does not mean identical animation everywhere; it means predictable behavior per template.
Testing beyond automated scans
axe catches many issues; it does not catch “this reveal feels seasick.” I tab through the entire home route monthly, and I voice-check with NVDA on Windows for landmark structure (main, nav, footer).
I also test at 200% zoom and with Windows high contrast themes—not because every user runs them, but because focus and border tokens that only work in bespoke dark mode break immediately under forced colors.
Shipping checklist
Before launch I verify: all text pairs pass contrast in context, reduced motion removes non-essential movement, focus is visible on every interactive control, decorative canvas is aria-hidden, and keyboard path reaches contact without traps. Dark, motion-forward portfolios can meet people where they are when those items are design constraints from day one, not a pre-launch panic audit.