Step 6 - Visual design and motion
4 min read
Visual design and motion
Plain CSS, no preprocessor, no framework. Custom properties carry the design decisions; component stylesheets consume them and never hard-code a colour or a spacing value.
Tokens (src/styles/tokens.css)
Define on :root:
:root {
--color-bg: #f7f7f5;
--color-surface: #ffffff;
--color-border: #d6d3cd;
--color-text: #1c1b19;
--color-text-muted: #5d5a54;
--color-x: #b3402a; /* warm */
--color-o: #2f5d8c; /* cool */
--color-accent: #1c1b19;
--color-focus: #0b62d0;
--color-win: #157347;
--space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem;
--space-4: 1rem; --space-6: 1.5rem; --space-8: 2rem;
--radius-sm: 6px; --radius-md: 12px; --radius-lg: 20px;
--border-width: 2px;
--font-ui: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-size-status: clamp(1rem, 2.5vw, 1.25rem);
--font-size-mark: clamp(2.5rem, 12vw, 4rem);
--duration-fast: 120ms; --duration-base: 200ms;
--easing: cubic-bezier(0.2, 0, 0.2, 1);
--board-max: 420px;
}
@media (prefers-color-scheme: dark) {
:root { /* redefine the colour tokens only — never the spacing or type scale */ }
}
Dark values must keep the same contrast guarantees: body text at least 4.5:1 against
--color-bg, and cell borders at least 3:1. Verify both themes with a contrast checker, do not
eyeball it.
Only system fonts. No web font is downloaded — that is a network request, and the constitution forbids them.
Layout
- The page is a centred column:
max-width: 32rem,margin-inline: auto,padding: var(--space-6). - The board is a
display: gridwithgrid-template-columns: repeat(3, 1fr),gap: var(--space-2), andwidth: min(90vw, var(--board-max)). - Cells are square via
aspect-ratio: 1, not padding hacks. - The row wrappers required by
role="row"aredisplay: contentsso they carry semantics without disturbing the grid. - Marks are centred with flexbox and sized from
--font-size-mark. - Below 380 px the controls stack vertically; above 640 px they sit in one row. Two breakpoints, no more.
- Nothing on the page may cause horizontal scrolling at 320 px width.
- Touch targets are at least 44 x 44 CSS pixels, including the controls.
Cell states
| State | Treatment |
|---|---|
| Empty, playable | Surface background, visible border, pointer cursor |
| Hover (playable) | Slightly darker surface, and a low-opacity preview of the current mark |
| Focus | 3 px --color-focus outline with 2 px offset, never removed |
| Occupied | Mark in --color-x or --color-o, cursor: default |
| Winning | --color-win border, a subtle background tint, and a bolder mark weight |
| Disabled (game over / computer thinking) | Reduced opacity, cursor: not-allowed |
Use :focus-visible for the focus ring on pointer interaction, but keep a visible ring for keyboard
users in every case. The hover preview must not appear on touch devices — guard it with
@media (hover: hover).
Motion
Three animations, all short and all optional:
- Mark placement — the mark scales from 0.6 to 1 with a fade,
--duration-base. - Winning line — the three winning cells pulse their border once, staggered by 60 ms.
- Status change — the status text cross-fades over
--duration-fast.
Nothing else moves. No bouncing, no confetti, no layout shift: the board's geometry is identical before and after any animation.
Wrap every animation in a guard:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
With reduced motion enabled the game must remain fully legible and every state change still visible — the final frame is what matters, so no information may live only in the animation.
Acceptance criteria
- All colours, spacings, radii, durations and type sizes come from custom properties; no component stylesheet contains a raw hex colour or a magic pixel value.
- The dark theme activates from
prefers-color-schemeand redefines only colour tokens. - Body text meets 4.5:1 and UI boundaries meet 3:1 contrast in both themes, checked with a tool.
- The layout is usable with no horizontal scroll from 320 px to 1920 px wide.
- Cells are perfectly square at every viewport width, via
aspect-ratio. - All interactive targets are at least 44 x 44 CSS pixels.
- Focus is always visibly indicated; no rule sets
outline: nonewithout an equivalent replacement. - The winning line is distinguished by border, tint and weight — not by colour alone.
- The hover preview appears only on devices that support hover.
- With
prefers-reduced-motion: reduce, no animation or transition runs and every state remains distinguishable. - No web font, image or other asset is requested from a third-party origin.
- Total CSS is under 12 kB uncompressed.
-
npm run verifypasses.
Discussion
0 commentsNo comments yet. Start the discussion.