TH The Loading Loop
Spinner Recipes

CSS Line Loader: A Bar With Honest Loading States

CSS Line Loader: A Bar With Honest Loading States
tldrA CSS line loader uses a clipped track and a narrower pseudo-element animated with translateX. Treat the moving bar as decoration, not a completion percentage. Keep visible text in one persistent status region, provide a static reduced-motion state, and remove the visual when the actual operation succeeds or fails. Test the complete interaction with your supported browsers and assistive technologies.

How do you make a CSS line loader?

Build a CSS line loader from one decorative element with a clipped, narrower pseudo-element moving across it with transform: translateX(). Pair it with visible status text, keep reduced motion static, and hide the line when the actual task succeeds or fails. The sweep indicates an indeterminate wait: its position and width are not completion percentages.

This recipe adds the details a thin loading line needs outside a visual demo: exact travel geometry, a motion limit, one persistent status, and a working local lifecycle demonstration. For a circular alternative, use the CSS loading spinner recipe.

Copy the markup and CSS

Paste the HTML into a document body and the CSS into its stylesheet. The three buttons deliberately control a local demonstration; they do not contact a server or simulate measured download progress. “Finish” and “Fail” affect only a pending demo.

<p>Local demo: start a wait, then choose its outcome.</p>
<button id="line-start" type="button">Start demo</button>
<button id="line-finish" type="button">Finish demo</button>
<button id="line-fail" type="button">Fail demo</button>

<div id="line-visual" class="line-loader" aria-hidden="true" hidden></div>
<p id="line-status" role="status" aria-atomic="true"></p>
<div id="line-results" aria-busy="false">Existing demo content.</div>
.line-loader {
  position: relative;
  width: 100%;
  max-width: 24rem;
  height: 0.25rem;
  overflow: hidden;
  border-radius: 999px;
  background: #dbeafe;
}

.line-loader[hidden] { display: none; }

.line-loader::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 40%;
  background: #1d4ed8;
  border-radius: inherit;
  transform-box: border-box;
  transform: translateX(75%);
}

@keyframes line-sweep {
  from { transform: translateX(-100%); }
  to { transform: translateX(250%); }
}

@media (prefers-reduced-motion: no-preference) {
  .line-loader:not([hidden])::before {
    animation: line-sweep 1.2s linear 3;
  }
}

The visual loader itself is one element. Its ::before pseudo-element supplies the moving segment. The status paragraph is separate information, not an extra piece needed to draw the line.

The line takes its container's width up to 24rem. Change that cap for your layout; keep width: 100% to accommodate narrower containers. Reserve a row for it in the surrounding layout if showing and hiding it would otherwise move content.

Why do the translations use those percentages?

MDN's translateX reference specifies that percentage translation uses the reference box established by transform-box. Here that is the moving segment's border box, not the track.

The segment is 40% of the track. For an illustrative 300-pixel track:

State Calculation Segment's left edge
Beginning 120 × -1 -120 pixels
End of sweep 120 × 2.5 300 pixels
Static center 120 × 0.75 90 pixels

At the beginning its right edge is zero; at the end its left edge reaches the track's far edge. Clipping hides the off-track portion. The static segment extends from 90 to 210 pixels, centered in the 300-pixel track.

Those values are geometry, not progress data. If you change the segment width, recalculate travel and centering rather than copying these translations unchanged.

Why does the animation stop while loading continues?

The base style is static. Motion is enabled only when prefers-reduced-motion: no-preference matches; a reduced-motion preference leaves the centered segment and visible message intact. MDN documents the media feature's values.

Even without that preference, three 1.2-second sweeps take 3 × 1.2 = 3.6 seconds, after which the base static style returns. This is a deliberate motion limit, not a task deadline. “Loading demo content...” remains until the Promise settles.

Do not casually replace the iteration count with infinite. WCAG 2.2, Success Criterion 2.2.2 requires a pause, stop or hide mechanism for moving information that starts automatically, lasts more than five seconds and appears alongside other content, unless the movement is essential. A reduced-motion query alone does not address every user's need to stop ongoing animation.

Connect the line to a real state transition

Put this script after the markup. Start creates a pending Promise. Finish fulfills it; Fail rejects it. There is no timer that declares success.

const start = document.querySelector("#line-start");
const finish = document.querySelector("#line-finish");
const fail = document.querySelector("#line-fail");
const visual = document.querySelector("#line-visual");
const statusText = document.querySelector("#line-status");
const results = document.querySelector("#line-results");
let pending = null;

start.addEventListener("click", async () => {
  if (pending) return;
  start.disabled = true;
  visual.hidden = false;
  results.setAttribute("aria-busy", "true");
  statusText.textContent = "Loading demo content...";

  try {
    const text = await new Promise((resolve, reject) => {
      pending = { resolve, reject };
    });
    results.textContent = text;
    statusText.textContent = "Demo content loaded.";
  } catch {
    statusText.textContent = "Demo failed. Select Start demo to retry.";
  } finally {
    pending = null;
    visual.hidden = true;
    results.setAttribute("aria-busy", "false");
    start.disabled = false;
  }
});

finish.addEventListener("click", () => {
  pending?.resolve("New local demo content.");
});
fail.addEventListener("click", () => {
  pending?.reject(new Error("Demonstration failure"));
});

The disabled Start button and pending guard allow one operation at a time. Finish or Fail while idle does nothing. Both terminal paths hide the line, clear busy state and permit another attempt. Failure leaves the previous content in place with an explicit error message.

For production, replace the manually settled Promise with your actual operation and remove the demonstration outcome buttons. Handle its failure, timeout and cancellation explicitly. If the design allows overlapping requests, add cancellation or response-ownership logic before reusing this pattern; this single-flight demo does not implement concurrent requests. MDN's Promise reference explains fulfillment, rejection and why a Promise itself has no first-class cancellation protocol.

What gets announced?

The decorative line is aria-hidden. The existing status paragraph receives text changes; it is never recreated or hidden with the line. WAI-ARIA 1.2 gives status implicit polite live-region semantics. The explicit atomic attribute asks for the complete message.

The busy attribute belongs on the updating results, not on an ancestor enclosing the status. Otherwise the loading announcement could be deferred with the content update. Do not also make the results a live region for the same message.

W3C technique ARIA22 checks that the status container exists before the update. This script changes its text only at meaningful state transitions, not at every animation frame. That avoids generating repeated frame-by-frame status changes; it does not guarantee identical announcements across assistive technologies. See the fuller accessible loading indicators guide for integration considerations.

What should you test before shipping?

This exact demonstration was executed in headless Microsoft Edge 152.0.4191.66. DOM and computed-style checks covered start, fulfillment, rejection, retry, one status mutation while advancing animation time, sweep endpoints and reduced-motion emulation at a narrow viewport. Those checks are not a screen-reader audit, physical-device benchmark or accessibility certification.

Test your supported browsers, keyboard flow, screen readers, zoom, color themes and actual application states. Keep visible loading text legible; a static segment alone is ambiguous. Check that success and failure both remove the visual, and that a slow task remains accurately labeled after motion ends.

Only transform changes during the sweep. MDN's animation-performance guide explains why composited transforms can avoid layout and repaint work. That is not a smoothness guarantee: profile the real page on representative hardware, especially with multiple loaders.

If completion is measurable, choose a labeled native progress element with values derived from the task. Without a value it is indeterminate, as MDN explains. Never derive a completion value from this decorative sweep. The spinner recipes hub collects other shape-specific patterns.

Sources

FAQ

Does a CSS line loader need JavaScript?

CSS can draw and animate the line, but it does not know whether an application operation is pending, successful or failed. JavaScript or framework state must connect those outcomes to the visual and status text. The recipe's local demonstration uses manually settled Promises to make those transitions inspectable.

Why does the moving segment use translateX(250%)?

In this recipe, percentage translation is relative to the moving segment's border box. The segment is 40% of its track, so translating it by 250% of its own width moves its left edge one full track width. Change the segment width and you must recalculate that endpoint.

Does the static bar mean loading has finished?

No. The example limits decorative motion to three 1.2-second sweeps, then keeps a centered static segment and visible loading text while work remains pending. Reduced-motion users receive the static state immediately. Only fulfillment or rejection of the actual operation should end its loading state.

Should a screen reader announce each sweep?

No. The sweep is decorative and does not represent a new application state. Keep it hidden from the accessibility tree and update a persistent status message only when meaningful state changes occur. Test actual announcement behavior with supported screen readers and browsers rather than assuming identical output everywhere.

Can I use the line's position as a progress percentage?

No. Its position follows an animation timeline, not completed work. Use measured task values for determinate progress, with appropriate labeling and semantics such as a native progress element. When the amount completed is unknown, keep the indicator indeterminate and describe what the application is doing.