Back to scenario guides
Scenario guide 03

Diagnose CSS motion jank

Jank rarely comes from one transition. Layout shifts, expensive paint, scroll work, and long durations can all make motion feel sluggish.

CSS motion jank becomes tractable when it returns to the task a person is trying to complete: identify the input, frame, and rendering work that breaks the feedback chain, then use repeatable evidence to narrow the cost of properties, scripting, and visual layers.

Make this decision first

Reproduce the clearest case, then identify whether it happens on start, during travel, or when the UI settles.

Implementation path

  1. 01

    Inspect frame time first

    Record one interaction in a performance panel and mark long tasks and dropped-frame stretches.

  2. 02

    Narrow layout-triggering properties

    Let transform and opacity carry visual change where possible; reserve size and layout work for cases that need it.

  3. 03

    Return duration to interaction rhythm

    Brief feedback often settles in 120–240ms. Scale list and state changes with content, without stretching every action.

Build a reproduction around one real moment

A report that the page feels slow is difficult to turn into a repair task. Shrink it into one action a person can repeat: type a filter into a list of 300 results, open detail after scrolling to the sixth viewport, drag a timeline handle, or press Save on a slower connection. Record the starting state, visible result, viewport, data volume, network condition, and number of consecutive actions. Ask the same reviewer to repeat the action three to five times, then mark whether input receives a late response, travel pauses in the middle, or content jumps only after motion ends. Each symptom opens a different investigation, so this record becomes the shared starting point for performance work.

A reproduction also needs to preserve context. Many issues appear only while fonts are arriving, image dimensions are still settling, search results keep changing, a background tab returns to the foreground, or a device enters a lower-power condition. Put those conditions in the steps so an occasional hitch can be separated from a stable regression. For high-frequency paths, keep a fixed seeded data set and a small visual capture. The capture shows the instant where rhythm is lost, while the data set gives engineering the same pressure on every check. Performance work then has comparable before-and-after states, and a repair can be verified through the actual interaction instead of a subjective impression on one desktop machine.

Input, script, style and layout, paint, and compositing connect in sequence; layout and paint use warning color to show work that needs a narrower scope.InputGesture andkeyboardeventScriptState andcomputationStyle andlayoutControlread/writethrashPaintLimit broadeffectsCompositePreferredhome forlightfeedback
Diagram 1: work across one frameInput, script, style and layout, paint, and compositing connect in sequence; layout and paint use warning color to show work that needs a narrower scope.

Locate cost through a frame’s rendering path

When a browser draws a frame, it commonly moves through event handling and scripting, style calculation, layout, paint, and compositing. Start a performance recording by aligning the visual capture with the timeline. On the frame where a card pauses, is there a long script block on the main thread? Are style and layout repeating? Is a broad paint, image decode, or third-party script holding time? Read dropped-frame spans and long tasks in the overview before entering the matching call stack; this is faster than guessing line by line from the start of a flame chart. The useful conclusion is concrete: for example, this scroll reads ten sizes and writes ten positions in one frame. That gives the next change a clear target.

Frame budget must be understood through refresh rate and device margin. A 60 Hz display delivers a frame about every 16.7 ms, a 120 Hz display leaves less time, and a phone’s temperature, battery, background activity, and GPU capacity can narrow the room further. A budget does not require every action to look identical. It protects prompt response for important input. Scrolling, dragging, continuous typing, and media scrubbing involve ongoing input and deserve priority. Initial list entrance, decorative glows, and broad background changes can yield resources. Record once on an ordinary laptop, once on a common phone, and once with a larger data set; that lets a smooth-on-desktop conclusion meet real operating conditions.

Let property choice serve the visual job

Property choice determines how far the browser must recalculate on each frame. A button press, an entering notice, a small card move, and a fade usually express feedback, direction, or hierarchy. `transform` and `opacity` serve those jobs well and often allow the browser to handle them in compositing. Expanding content, real text wrapping, changing grid columns, and changing image ratio express actual layout, so size and layout updates carry product meaning. Both categories deserve implementation. The useful boundary is clear: put light feedback on compositor-friendly properties, constrain real structural change to the smallest practical container, and avoid turning every frame into a whole-page recalculation.

Layout thrash often comes from alternating reads and writes. Code reads an element’s `offsetHeight`, writes `style.height` immediately, then reads another element’s position; that can force the browser to settle unfinished work early. Gather reads in one phase, gather writes in the next frame, or hand a calculated value to a CSS custom property to reduce the back-and-forth. `will-change` also belongs on the small number of motions about to occur and for a short time. Permanently promoting every card in a long list adds memory, compositing, and scrolling pressure. The quality of a performance repair comes from tradeoffs that fit the task, not from filling a style sheet with a fixed recipe.

Light feedback and real structural change begin with visual jobs, then connect to compositor-friendly properties and localized layout updates.Short feedbackReal changeLightfeedbackPress,notice, smalltravelCompositorpropertiestransform ·opacityRealstructureDisclosure,text, gridLocalizedlayoutConstrainaffectedscope
Diagram 2: choose properties by visual jobLight feedback and real structural change begin with visual jobs, then connect to compositor-friendly properties and localized layout updates.

Keep continuous input causally connected

When someone types a filter continuously, every key event may trigger state change, search, list reorder, and entrance or exit motion. If each update waits for a previous 400 ms animation to finish, the interface builds a visual queue and input gradually loses its connection to results. A better strategy keeps the latest intent. When a new query arrives, cancel the old decorative transition, update the result count and active criteria immediately, then let only a small number of new items appear briefly. During scrolling and dragging, position following the hand is core feedback. Inertia, shadows, blur, and trails are secondary effects that can narrow with device capability and preference. People can then see what their input is controlling at every moment.

Moving expensive work out of high-frequency callbacks matters as well. A scroll listener can collect visible range and direction, then write visual state on the next frame with `requestAnimationFrame`. Search can debounce truly expensive local or remote computation while the input and result count respond immediately. Dragging can commit data after release while keeping display position in a lightweight state. The goal is a stable priority order: gesture and typed input receive acknowledgement first, important content updates next, and decorative detail joins only when margin remains. Performance and experience are the same concern here because both protect the person’s sense of control.

Use baselines and acceptance to protect the next change

A repair improves the product only when it still holds after the next feature iteration. Keep a lightweight baseline for high-frequency components: a fixed data set, reproduction steps, device and viewport, a ten-to-twenty-second capture, and the noteworthy span in one performance recording. A baseline can cover mobile filtering, desktop dragging, opening a large detail view, and saving on a slower connection. It does not need to become an expensive lab system. It gives code review and regression work a shared reference. When a new animation, image asset, or third-party component arrives, the team can quickly ask which baseline it affects and whether it consumes margin reserved for continuous input.

Acceptance language should also describe what a person can accomplish. Instead of writing only keep 60 fps, write that the result count changes immediately after typing a filter, the primary action remains usable while scrolling a specified number of cards, focus and content remain stable during drawer opening, and state stays fully legible with reduced motion enabled. Then add technical evidence for those goals: a long-task threshold, a dropped-frame span in recording, number of layout reads and writes, or an acceptable number of visual layers. Product goals beside technical signals prevent a metric from sacrificing feedback and prevent a visual effect from ignoring cost. A short review before each release makes performance part of the component contract.

Reproduce, record, locate, change, and regress form a loop; a regression issue returns to reproduction.Verify againReproduceFix a realinteractionRecordAlign captureand timelineLocateIdentify workandpropertiesChangeNarrow costor durationRegressCheckbaselinedevices
Diagram 3: jank diagnosis and regression loopReproduce, record, locate, change, and regress form a loop; a regression issue returns to reproduction.

CSS motion performance shipping checklist

  • The issue reproduces under fixed conditionsRecord device, viewport, data volume, network, starting state, and repeated actions.
  • Visual capture aligns with the performance timelineMatch the visible pause to evidence from scripting, layout, paint, or resource decoding.
  • Light feedback uses an appropriate rendering pathUse transform and opacity for travel and fades, then constrain real structural change to a local container.
  • The latest input always has priorityContinuous search, scrolling, and dragging cancel stale decorative transitions and update essential state promptly.
  • Common devices and the reduced-motion path have regressedUse a fixed data set to check an ordinary laptop, phone, large list, and system-preference change.

Case: filter cards stay lightweight during continuous input

A result list updates while someone keeps typing. Cards carry only short entrance and exit feedback, real order comes from data updates, and an old transition can yield to the latest query.

<section class="filter-demo">
  <label>
    Filter projects
    <input type="search" data-filter-input placeholder="Type a project name">
  </label>
  <p role="status" aria-live="polite" data-result-count></p>
  <ul class="result-list" data-result-list></ul>
</section>

<style>
.filter-demo { display: grid; gap: 12px; max-width: 32rem; }
.result-list { display: grid; gap: 8px; padding: 0; list-style: none; }
.result-card { opacity: 1; transform: translateY(0); transition: opacity 160ms ease-out, transform 160ms ease-out; }
.result-card[data-motion="enter"] { opacity: 0; transform: translateY(8px); }
.result-card[data-motion="leave"] { opacity: 0; pointer-events: none; transform: scale(.985); }
@media (prefers-reduced-motion: reduce) {
  .result-card { transition-duration: 1ms; }
}
</style>

<script>
const input = document.querySelector("[data-filter-input]");
const resultList = document.querySelector("[data-result-list]");
const resultCount = document.querySelector("[data-result-count]");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const projects = ["Alpha dashboard", "Beta settings", "Gamma archive", "Delta reports"];
let renderFrame = 0;
let leaveTimer = 0;

function matchingProjects(query) {
  const normalizedQuery = query.trim().toLowerCase();
  return projects.filter((project) => project.toLowerCase().includes(normalizedQuery));
}

function updateResultCount(results) {
  resultCount.textContent = results.length + " project" + (results.length === 1 ? "" : "s") + " shown.";
}

function createResultCard(project) {
  const item = document.createElement("li");
  item.className = "result-card";
  item.dataset.motion = "enter";
  item.textContent = project;
  return item;
}

function commitResults(results) {
  resultList.replaceChildren(...results.map(createResultCard));
  renderFrame = window.requestAnimationFrame(() => {
    resultList.querySelectorAll("[data-motion='enter']").forEach((card) => {
      card.removeAttribute("data-motion");
    });
  });
}

function applyLatestQuery(query) {
  window.cancelAnimationFrame(renderFrame);
  window.clearTimeout(leaveTimer);
  const results = matchingProjects(query);
  updateResultCount(results);
  const currentCards = Array.from(resultList.children);

  if (reducedMotion.matches || currentCards.length === 0) {
    commitResults(results);
    return;
  }

  currentCards.forEach((card) => {
    card.dataset.motion = "leave";
  });
  leaveTimer = window.setTimeout(() => commitResults(results), 160);
}

input.addEventListener("input", () => applyLatestQuery(input.value));
applyLatestQuery("");
</script>

The code first cancels stale decorative transitions, then updates count and results together. Entrance and exit change only opacity and transform while data and layout retain ownership of real order. The reduced-motion path uses the same state and compresses the transition to a very short interval.