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: grid with grid-template-columns: repeat(3, 1fr), gap: var(--space-2), and width: min(90vw, var(--board-max)).
  • Cells are square via aspect-ratio: 1, not padding hacks.
  • The row wrappers required by role="row" are display: contents so 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:

  1. Mark placement — the mark scales from 0.6 to 1 with a fade, --duration-base.
  2. Winning line — the three winning cells pulse their border once, staggered by 60 ms.
  3. 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-scheme and 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: none without 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 verify passes.

Discussion

0 comments

No comments yet. Start the discussion.