Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 3.6Beginner ~22 min · shot 6 of 6

Build: Button and Link Feedback

In one line

Give every button and link on the launch page a hover colour, a quick press and a keyboard focus ring, all in 120 ms with the ease-out token. The colours always change, and the press is skipped for people who ask for less motion.

0/6 · 0%

Before you start

You will learn

  • Add one marked Phase 3 block to components.css
  • Use the tokens from Phase 2 for every time and curve
  • Check hover, press, focus, disabled and touch behaviour on the launch page

You write code in

In your lumen-motion project: edit src/styles/components.css, then watch npm run dev

Files you touch

  • src/styles/components.css

Think of it like this

The buttons in a lift. Press one and it lights up at once, it clicks down a little, and you know the lift heard you. A lift that said nothing would make everyone press three times. Lena's testers did exactly that on the Reserve button.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

:focus-visible
A pseudo-class that matches a focused element when the browser decides the user needs to see focus, usually after keyboard use.
outline
A line drawn around an element outside its border that does not change the layout. Used here for the focus ring.
:active
A pseudo-class that matches an element while it is being pressed by a mouse button, a finger or the Enter or Space key.
forced colors
An operating-system mode (such as Windows high contrast) that replaces page colours. Transparent outlines still show in it.

Step by step

01Open components.css

Open src/styles/components.css. Change its first comment line to mention Phase 3, and leave everything from Phases 0 and 1 (including the loading-bar block) as it is.

02Paste the Phase 3 block at the end

Paste the block between the Phase 3 comments at the end of the file. It depends on --dur-fast and --ease-out from tokens.css, which Phase 2 created and index.html already links.

03Try every state

Run npm run dev. Hover the Reserve button and a nav link. Press and hold a button. Press Tab to move the ring through the page. Check that Sold out (disabled) does nothing.

04Check reduced motion

In the example below switch on the timeline's Reduced motion, press and hold a button: the colour still changes and the button does not shrink.

Try it yourself

  1. 1

    Read the press frame by frame

    In the example, hold the mouse button down on Reserve. Its transform goes from none to matrix(0.97, 0, 0, 0.97, 0, 0); in a real mouse-down test the value after 250 ms was exactly that, and after release it went back to none. Click its track while it moves to read the 120 ms duration.

  2. 2

    Switch reduced motion on

    Turn on the timeline's Reduced motion and press and hold a button again: the background still darkens, the transform stays none.

Code & diagrams

Button and link feedback

A compact version of the launch page's header and hero with your tokens and the finished components.css inlined (local imports cannot run in the preview). Hover, press, Tab through the controls, and try the disabled button.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

reserve transition: background-color, border-color, color, outline-color, transform
reserve duration:   0.12s, 0.12s, 0.12s, 0.12s, 0.12s
reserve outline:    solid 2px rgba(0, 0, 0, 0)

Your project files

Your lumen-motion project after this topic. Click a file to see it in full.

src/styles/components.csscss

The whole file after this topic. If yours differs, you can copy this one and carry on.

/* components.css: buttons, cards, gallery and specs. Motion arrives phase by phase; Phase 3 adds button and link feedback. */
button { font: inherit; cursor: pointer; }
.button, .add, .filter, .cart-button { padding: 8px 16px; border-radius: 10px; border: 1px solid #0002; background: #fff; color: var(--ink); }
.primary { background: var(--orange); border-color: var(--orange); color: #fff; font-weight: 700; }
.cart-button span { display: inline-block; min-width: 1.5em; margin-left: 4px; border-radius: 99px; background: var(--orange); color: #fff; text-align: center; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; }
.card { padding: 14px; border-radius: 14px; background: #fff; border: 1px solid #0001; }
.card h3 { margin: 0; font-size: 16px; }
.card p { margin: 4px 0 10px; }
.filters { display: flex; gap: 8px; margin-bottom: 12px; }
.filter[aria-pressed="true"] { background: var(--ink); color: #fff; }
.photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
.photo { margin: 0; }
.photo .swatch { aspect-ratio: 4 / 3; border-radius: 12px; }
.photo[data-color="orange"] .swatch { background: var(--orange); }
.photo[data-color="blue"] .swatch { background: var(--blue); }
.photo[data-color="ink"] .swatch { background: var(--ink); }
.photo figcaption { font-size: 13px; margin-top: 4px; }
.spec { padding: 10px 14px; margin-bottom: 8px; border-radius: 10px; background: #fff; border: 1px solid #0001; }
.spec summary { cursor: pointer; font-weight: 600; }

/* ── Phase 1: the loading bar ── */
.loading-wrap { margin-top: 20px; max-width: 260px; }
.loading { height: 6px; border-radius: 3px; background: #0001; overflow: hidden; }
.loading-fill { height: 100%; background: var(--orange); transform: scaleX(0); transform-origin: left; } /* scaleX: cheap, no layout */
.loading-label { margin: 6px 0 0; font-size: 13px; }
/* ── end Phase 1 ── */

/* ── Phase 3: button and link feedback ── */
/* Colours always change; the press scale is for people who have not asked for less motion (the rule is inside a media query).
   Every control gets the same short colour transition, a transparent ring that turns blue on keyboard focus, and a press. */
.button, .add, .filter, .cart-button, .primary {
  outline: 2px solid transparent; /* always there, so the ring can change colour only, and still shows in forced-colors mode */
  outline-offset: 2px;
  transition:
    background-color var(--dur-fast) var(--ease-out),
    border-color var(--dur-fast) var(--ease-out),
    color var(--dur-fast) var(--ease-out),
    outline-color var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}
/* Hover only where there is a pointer that can hover, so a tap never leaves a stuck hover state. */
@media (hover: hover) {
  .button:not(:disabled):hover, .add:not(:disabled):hover, .cart-button:not(:disabled):hover,
  .filter:not([aria-pressed="true"]):hover { background: #efe9df; border-color: #0004; }
  .primary:not(:disabled):hover { background: #e85d1e; border-color: #e85d1e; }
}
/* The press: works with a mouse, a finger or the Enter and Space keys. Only for people who have not asked for less motion. */
@media (prefers-reduced-motion: no-preference) {
  .button:not(:disabled):active, .add:not(:disabled):active, .filter:active, .cart-button:active, .primary:not(:disabled):active {
    transform: scale(0.97);
  }
}
/* Keyboard focus: the ring changes colour. Nothing moves. */
.button:focus-visible, .add:focus-visible, .filter:focus-visible, .cart-button:focus-visible, .primary:focus-visible {
  outline-color: var(--blue);
}
.primary:disabled { opacity: 0.7; cursor: default; }
/* Links: the underline fades in, in the same time and curve. */
.site-header nav a {
  text-decoration: underline;
  text-decoration-color: transparent;
  text-underline-offset: 4px;
  outline: 2px solid transparent;
  outline-offset: 3px;
  border-radius: 4px;
  transition: color var(--dur-fast) var(--ease-out), text-decoration-color var(--dur-fast) var(--ease-out), outline-color var(--dur-fast) var(--ease-out);
}
@media (hover: hover) {
  .site-header nav a:hover { color: var(--orange); text-decoration-color: currentColor; }
  .logo:hover { color: #e85d1e; }
}
.site-header nav a:focus-visible, .logo:focus-visible { outline-color: var(--blue); }
.logo { transition: color var(--dur-fast) var(--ease-out); outline: 2px solid transparent; outline-offset: 3px; border-radius: 4px; }
/* ── end Phase 3 ── */

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Reset the press in a reduced-motion query with a weaker selector

Replace the press block with .primary:not(:disabled):active { transform: scale(0.97); } and, in @media (prefers-reduced-motion: reduce), add .primary:active { transform: none; }.

terminal
$ Switch to reduced motion and press and hold Reserve
── what you'll see ──
transform: matrix(0.97, 0, 0, 0.97, 0, 0)
Measured with reduced motion emulated: the press still scaled.

Myth vs fact

Myth

Buttons need a big animation to feel responsive.

Fact

A 120 ms colour change and a 3% press are enough, and they are cheap. The point is to confirm the press, not to show off.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The transitioned list names outline-color but not outline, outline-width or outline-offset: they never change, so nothing else needs to animate. The ring is an accessibility feature, so only its colour is allowed to change: never remove it with outline: none unless you draw a replacement that is at least as visible.

  • ▸

    A scale transform can soften text while it is applied, which is one reason the press stays small and short. Topic 6.3 covers layers and blurry text.

Remember this

  1. 1

    Why the site needs this now: Lena watched a tester press Reserve twice because nothing answered. Kofi can fix that with CSS alone, using only what this phase taught: a transition on the base rule, a short duration, properties that are cheap, and no surprises from all.

  2. 2

    What it adds. One block in src/styles/components.css, marked Phase 3. Every button (.button, .add, .filter, .cart-button, .primary) and header link gets: a hover colour in 120 ms (var(--dur-fast) with var(--ease-out)); a press that scales to 0.97; and a keyboard focus ring whose only change is its colour. It uses the tokens file from Phase 2.

  3. 3

    The ring never moves. Each control has outline: 2px solid transparent all the time. On :focus-visible the colour changes to the Lumen blue, and the transition goes from transparent to blue in 120 ms. Nothing changes size or position, so a keyboard user's tab moves are never delayed. A transparent outline also still shows in forced-colors (high contrast) mode, where the system paints it.

  4. 4

    The press is for people who accept motion. The scale rule sits inside @media (prefers-reduced-motion: no-preference). Reduced-motion users still get the colour change on hover and the focus ring, and lose only the shrinking. (A reset in a reduce query with lower specificity does not work: the press still scales, as the break-it below shows. Wrapping the rule itself is safer.) Phase 7 adds the site-wide switch.

  5. 5

    Touch. Hover styles are inside @media (hover: hover), so a tap never leaves a stuck hover colour (in a touch-emulated browser the query was false and the hover rules did not apply). The press uses :active, which is meant to be the touch feedback: it should apply while a finger is down. This is untested here: the preview cannot produce a real touch, and mobile browsers have differed on when they set :active for a touch. Test it on a real phone, as Lena does.

  6. 6

    Edge cases handled. A disabled button gets no hover and no press (:not(:disabled), measured: the Sold out button stayed at transform: none while pressed). A pressed filter (aria-pressed="true") does not change to the hover colour, which would have put dark text on a pale hover background. Links get an underline that fades from transparent, so keyboard focus and hover are distinct.

  7. 7

    What the launch site can do now that it could not: every control answers a pointer, a finger and a keyboard within a tenth of a second, and the answer is calm. Still missing: a toast that confirms an action (Phase 4), the reveal of the accessory grid (Phase 5), the cart bump and drawer (Phases 8 and 9), the site-wide reduced-motion switch (Phase 7).

Explain it without notes

01

Why is the focus ring's colour transitioned but its width and offset are not?

02

Why is the press scale wrapped in prefers-reduced-motion: no-preference and not reset in a reduce query?

03

Why are the hover rules inside @media (hover: hover)?

Practice

01

Add feedback for the gallery photos: a hover that brightens the swatch in 120 ms and no press effect, for pointer devices only.

02

Add a .danger button style with a red hover and the same press, using the tokens.

Done when you can

  • Every button and link on the page answers hover, press and keyboard focus in 120 ms

  • The focus ring changes colour only and never moves the layout

  • The press is skipped for reduced-motion users and the colours stay

  • Disabled, pressed-filter and touch cases behave