Interaction & Experience Guide

💡 This is the human-facing version (read it to make decisions and understand the why). The matching coding guardrail lives in your project's dev standards as UX-001~UX-010 (synced into your CLAUDE.md, enforced when AI writes code). Same principles, two layers: here we explain why, the guardrail gives you do this.

Consistent interaction details are the cheapest — and most effective — source of "this feels professional." Users won't articulate "this button hovers differently from that one," but they will vaguely feel "this site is rough, not quite trustworthy." This guide lays out swift-web's recommended interaction baseline and how to land each point on framework components.

Why consistency = professionalism

Inside a product, users build expectations: hover a red button and see it darken, and the brain assumes "all red buttons darken on hover." That's predictability.

The moment same-type elements behave differently (brighten here, darken there), every "huh, why is this different?" costs the user an extra bit of cognitive load to re-understand. Trivial once, but it compounds across the whole site into "feels unpolished." Conversely, strict consistency makes the UI "invisible" — users stop thinking about the interaction itself and spend all attention on the content.

This is the core of Nielsen's heuristic "Consistency and standards." The four groups below apply that principle to concrete swift-web scenarios.

1. Feedback & States

1. Interaction feedback consistency: same type, same behavior (UX-001)

Same-type interactive elements (buttons / links / cards) must give consistent hover / active / focus feedback site-wide. The feedback method is picked by fill type, three tiers:

Fill typeHover feedbackWhy
Solid fill (brand / light / accent / colored icon)Darken brightness(0.92) (active 0.85)Most buttons; darkening is always perceptible on solid colors
Dark / semi-transparent dark fill (on dark surfaces)Brighten brightness(1.12)Darkening on dark sinks into the background — brighten instead
Semi-transparent pure white ghostRaise background opacity (e.g. 12% → 22%)White is already maxed — brightness can't move it

The two traps that bite most:

/* ❌ The same brand-red CTA darkens on login but brightens on the paywall — one button, two behaviors */
.login-submit:hover { filter: brightness(0.92); } /* darken */
.paywall-cta:hover  { filter: brightness(1.08); } /* brighten — inconsistent */

/* ❌ A semi-transparent white button trying to brighten — pure white (255) clamps back to 255, zero hover feedback */
.ghost-btn { background: rgb(255 255 255 / 12%); }
.ghost-btn:hover { filter: brightness(1.08); } /* no effect */
/* ✅ Pick the tier by fill type, consistent site-wide */
.cta:hover  { filter: brightness(0.92); }
.cta:active { filter: brightness(0.85); }

.dark-btn:hover { filter: brightness(1.12); }

.ghost-btn { background: rgb(255 255 255 / 12%); transition: background 0.15s; }
.ghost-btn:hover { background: rgb(255 255 255 / 22%); }

Where this rule came from: in a real paywall build, the brand-red CTA was written to brighten on the paywall while everything else on the site darkened — one red button, two hover behaviors. Unifying back to darken instantly brought the "professional" feel back. CSS specifics (filter: brightness vs background opacity) are in Styling & Tokens.

2. Lock the entry during async operations (UX-002)

Any clickable element that triggers an async operation (submit / upload / fetch / delete) must be disabled or show loading while the async runs — otherwise users can click repeatedly → double-submit, state corruption, duplicate requests.

With SwButton, pass :loading and the component disables itself and shows a loading state:

<SwButton :loading="submitting" @click="onSubmit">Submit</SwButton>
<script setup>
const submitting = ref(false)
async function onSubmit() {
  submitting.value = true
  try {
    await api.submit()
  } finally {
    submitting.value = false
  }
}
</script>

Non-button clickables (card clicks, custom triggers) follow the same idea — lock the entry while pending.

3. Data-driven views must cover all four states (UX-003)

A "fetch then render" view (list / detail / feed) that only draws the success state breaks in the other three: blank screen while loading, empty whitespace that looks broken when there's no data, frozen with no retry on failure. Draw all four:

  • loading → skeleton (preferred, same size as real content, which also prevents CLS) or spinner; never a blank screen
  • empty → a clear empty-state message + next step ("Nothing here yet, go explore" + an entry); not just whitespace
  • error → an error explanation + a retry entry; not frozen or silent
  • success → normal render

Several framework components have four-state hooks built in — prefer them: SwGallery (loading / error / finished), SwVideoFeed (finished, plus a built-in per-item loading / buffering indicator — you do not have to draw stall feedback yourself).

<template>
  <SkeletonList v-if="pending" />
  <ErrorState v-else-if="error" @retry="refresh" />
  <EmptyState v-else-if="!list.length" />
  <ContentList v-else :items="list" />
</template>

2. Accessibility & Operability

Accessibility isn't "an extra feature for a few people" — keyboard users, screen-reader users, and vestibular-sensitive users are all real users, and several points below map directly to WCAG clauses.

1. Respect prefers-reduced-motion (UX-004)

Translate / scale / parallax / autoplay carousels / large transitions can trigger dizziness and nausea for users with vestibular disorders (WCAG 2.3.3 Animation from Interactions). Non-essential motion must be turned off or downgraded under @media (prefers-reduced-motion: reduce):

.card { transition: transform 0.3s; }
.card:hover { transform: scale(1.05) translateY(-8px); }

@media (prefers-reduced-motion: reduce) {
  .card { transition: none; }
  .card:hover { transform: none; } /* or keep only a very subtle opacity cue */
}

Essential state feedback (loading spinners, etc.) can stay, but keep it restrained.

2. Dialogs must manage focus (UX-005)

A modal layer (modal / dialog / drawer) that ignores focus — focus left in the background, keyboard Tab escaping to the obscured content behind, focus lost on close — is completely unusable for keyboard and screen-reader users. The requirements: move focus into the dialog on open, trap Tab inside (focus trap), Esc to close, restore focus to the trigger on close.

The good news: SwModal / useModal() are built on reka-ui and handle all of this out of the box. So — use the framework dialog; don't roll your own div overlay (a custom one must implement trap + restore yourself, which is easy to miss).

3. Touch hit area compliance (≠ enlarging visuals) + non-reflowing focus indicator (UX-006)

  • What must be constrained is the hit area, not the visual size: tiny tap areas do cause mis-taps, but the specs govern the clickable region — WCAG defines a target as the "region of the display that will accept a pointer action", and Material Design 3 states touch targets "extend beyond the visual bounds of an element". The conformance baseline is 24×24 (WCAG 2.5.8 AA; 44×44 comes from AAA 2.5.5), and inline text links are explicitly exempt (body links / breadcrumbs / footer links are not subject to a minimum size).
  • framework ships no global min-height floor (removed in framework#265; it used to, which stretched icon buttons and misaligned breadcrumbs). Add sw-hit-area to compact controls smaller than 44px to opt into hit-area expansion with unchanged visuals; framework's own compact icon buttons already have it internally, so using Sw* components needs no touch-side patch. See Styling & Tokens · Responsive utilities.
  • Focus indicator without reflow: don't implement the focus cue by adding / widening a border (it changes the box size and shifts layout). For elements with a border, change border-color; for borderless ones, use outline or a box-shadow ring.

CSS specifics for both are in Styling & Tokens.

3. Navigation & Confirmation

1. Destructive actions need confirmation (UX-007)

Delete / clear / log out / irreversible charges are destructive — executing on click means one mis-tap causes unrecoverable loss.

  • High-risk / irreversible → confirm before executing, with useModal() (not the native confirm(): unstyleable, blocks the main thread, poor on mobile):
    async function onDelete() {
      const ok = await useModal().open({ title: 'Delete account?', description: 'This cannot be undone' })
      if (ok) await api.deleteAccount()
    }
    
  • Low-risk / reversible (archive, remove one item) → "execute + undo toast" can replace a hard confirm, interrupting the user less.

2. Pick the feedback channel by scenario (UX-008)

Using the wrong channel makes feedback fail — stuffing a decision into a fleeting toast, or reporting a field validation error via toast (gone before the user reads it). Pick by scenario:

ScenarioUseWhy
Success / light, non-blockingtoast — useToast()Brief, non-interrupting, auto-dismisses
Needs a decision / confirmmodal — useModal()Blocking, requires a response, can't be missed
Field-level validationinline, near the field — SwFormFieldShown where the error is, re-readable

Never use the native alert() (unstyleable, blocking).

// ❌ Toast for a form validation error — flashes by, the user misses it
useToast().error('Invalid phone number')

// ✅ Inline near the field; toast only on submit success
setFieldError('phone', 'Invalid phone number')
// ...after submit succeeds:
useToast().success('Saved')

4. Perceived Performance

"Perceived performance" is about whether the user feels it's fast, not just raw load time.

1. Prevent layout shift (CLS) (UX-009)

Images / ads / lazy-loaded content / web fonts arriving and shoving already-rendered content (Cumulative Layout Shift) — the button the user was about to tap gets pushed away, reading position jumps. It feels bad and tanks the mobile Lighthouse score.

The core fix: reserve placeholder dimensions for any async-inserted content. Images use SwImage with ratio (the component holds the container via aspect-ratio); ad slots get a fixed height; skeletons match the real content's size.

<!-- ratio holds the height, so the container doesn't jump before/after load -->
<SwImage :src="cover" alt="cover" ratio="16/9" />

2. Optimistic updates (opt-in) (UX-010)

Like / favorite / follow — low-risk, high-frequency actions feel sluggish if the UI waits for a network round trip every time. You can update optimistically: change the UI first, send the request, roll back + notify on failure.

// ✅ Optimistic like + rollback on failure
liked.value = true
try {
  await api.like(id)
} catch {
  liked.value = false
  useToast().error('Something went wrong, please retry')
}

The boundary (important): only low-risk, reversible actions qualify. Actions with side effects / irreversible / involving money (payment, deletion, publishing, placing an order) must never be optimistic — wait for server confirmation, or "optimistically showing success while it actually failed" misleads the user into an accident.

Industry references at a glance

This guide only covers swift-web's actionable recipes. For the general UX / accessibility theory, see these authorities (we don't copy their content here, to avoid going stale):

Relationship to the coding guardrail

This guide (the human long-form) and UX-001~UX-010 in your dev standards (the AI guardrail / MR-review checklist) are two layers of the same principles:

  • Writing code, passing review → defer to the guardrail (the "Interaction & Experience" section of your project CLAUDE.md), where each rule carries a counter-example + enforcement note.
  • Understanding why, making design decisions → read this guide, where the expanded why + examples + industry theory live.

The two cross-link and don't duplicate each other; this guide is synced by hand when the guardrail changes.