solid-route-progress

Styling

Custom properties

PropertyDefaultMeaning
--sp-coloroklch(0.65 0.14 241)bar color
--sp-height3pxbar thickness
--sp-z-index9999bar stacking order
--sp-start0.08value the bar is revealed at
--sp-trickle-duration10show long the loading drift takes
--sp-trickle-easinglinear(…)shape of the drift
--sp-speedthe speed optionread-only; set it through speed

Set them on :root, in Tailwind's @theme, on a class, or inline.

:root {
  --sp-color: oklch(0.62 0.19 264); /* or var(--color-indigo-500) */
  --sp-height: 2px;
}
:root {
  color-scheme: light dark;
  --sp-color: light-dark(oklch(0.55 0.2 264), oklch(0.75 0.12 264));
}

Utilities

<RouteProgress class="h-1 data-[state=done]:opacity-50" />

The stylesheet sits in the components.sprogress sublayer, so utilities, your own @layer components rules and unlayered CSS all beat it without !important.

Hooks for the rest of the page

HookWhereWhen
data-state="idle | trickle | active | done"the baralways
data-errorthe bardone phase of a failed load
data-sp-busy<html>while any bar is visible
<main class="transition-opacity [[data-sp-busy]_&]:opacity-60"></main>
<Progress busyAttribute={false} /> // opt out
<Progress ariaBusy /> // also aria-busy="true"

Inside a container

<div class="relative overflow-hidden rounded-xl">
  <Progress controller={panel} class="absolute" />
</div>

Your own template

import { Bar, useProgress } from 'solid-route-progress'

const Percent = () => <output>{Math.round(useProgress().value() * 100)}%</output>

<RouteProgress>
  <Bar class="rounded-r-full" />
  <Percent />
</RouteProgress>

--sp-value is registered as a <number>, so your own elements can transition it. Keep the var() fallbacks.

.ring {
  background: conic-gradient(
    var(--sp-color, oklch(0.65 0.14 241)) calc(var(--sp-value) * 1turn),
    transparent 0
  );
  transition: --sp-value var(--sp-trickle-duration, 10s) var(--sp-trickle-easing, ease-out);
}

Recipes

They are not shipped, so they cost nothing until you paste them. See them live in Examples.

Failed loads

.sprogress[data-error] {
  --sp-color: oklch(0.63 0.19 23);
}

Spinner

<RouteProgress>
  <Bar />
  <div class="spinner" aria-hidden="true" />
</RouteProgress>
.spinner {
  position: absolute;
  top: 15px;
  inset-inline-end: 15px;
  box-sizing: border-box;
  width: 18px;
  height: 18px;
  border: 2px solid transparent;
  border-block-start-color: var(--sp-color, oklch(0.65 0.14 241));
  border-inline-start-color: var(--sp-color, oklch(0.65 0.14 241));
  border-radius: 50%;
  animation: spinner 400ms linear infinite;
}
.sprogress[data-state='idle'] .spinner {
  animation: none;
}
@media (prefers-reduced-motion: reduce) {
  .spinner {
    display: none;
  }
}
@keyframes spinner {
  to {
    rotate: 1turn;
  }
}
<div
  aria-hidden="true"
  class="absolute end-4 top-4 size-4.5 animate-spin rounded-full border-2 border-transparent border-s-(color:--sp-color) border-t-(color:--sp-color) in-data-[state=idle]:animate-none motion-reduce:hidden"
/>

Glow

.sprogress-bar::after {
  content: '';
  position: absolute;
  inset-inline-end: 0;
  top: 0;
  width: 100px;
  height: 100%;
  box-shadow:
    0 0 10px var(--sp-color, oklch(0.65 0.14 241)),
    0 0 5px var(--sp-color, oklch(0.65 0.14 241));
  transform: rotate(3deg) translateY(-4px);
}
.sprogress:dir(rtl) .sprogress-bar::after {
  transform: rotate(-3deg) translateY(-4px);
}

Built in

RTLthe bar slides in from the right under dir="rtl"
Forced colorspaints with the system Highlight color

The stylesheet

/*
 * solid-route-progress — all rendering lives here. JS only writes `--sp-value`, `--sp-speed` and `data-state`.
 *
 * Tunables (set them anywhere: `:root`, `@theme`, a class, or inline):
 *   --sp-color            bar color                       oklch(0.65 0.14 241)
 *   --sp-height           bar thickness                    3px
 *   --sp-z-index          bar stacking order               9999
 *   --sp-start            value the bar is revealed at     0.08
 *   --sp-trickle-duration length of the loading drift      10s
 *   --sp-trickle-easing   shape of the loading drift       linear() curve
 *
 * `--sp-speed` mirrors the `speed` option so CSS transitions and JS timers agree: read it in
 * your own rules, set it through `speed`.
 *
 * Everything sits in the `components.sprogress` sublayer, so Tailwind utilities, your own
 * `@layer components` rules and unlayered CSS all win without `!important`.
 */

@property --sp-value {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@layer components.sprogress {
  .sprogress {
    position: fixed;
    inset: 0 0 auto 0;
    z-index: var(--sp-z-index, 9999);
    height: var(--sp-height, 3px);
    pointer-events: none;
    visibility: visible;
    transition:
      opacity var(--sp-speed) ease,
      visibility 0s;
  }

  .sprogress[data-state='idle'] {
    opacity: 0;
    visibility: hidden;
    /* keep painting while the opacity fades, then drop out of the a11y tree / hit-testing */
    transition:
      opacity var(--sp-speed) ease,
      visibility 0s var(--sp-speed);
  }

  .sprogress-bar {
    position: absolute;
    inset: 0;
    background: var(--sp-color, oklch(0.65 0.14 241));
    /* full-width bar slid in from the inline-start edge */
    transform: translateX(calc((var(--sp-value) - 1) * 100%));
    /* fast out of the gate, then a long crawl — never quite arriving */
    transition: transform var(--sp-trickle-duration, 10s)
      var(
        --sp-trickle-easing,
        linear(0, 0.25 5%, 0.4 10%, 0.55 18%, 0.7 30%, 0.8 45%, 0.88 60%, 0.94 75%, 0.98 90%, 1)
      );
  }

  .sprogress:dir(rtl) .sprogress-bar {
    transform: translateX(calc((1 - var(--sp-value)) * 100%));
  }

  /* hidden: park the bar at the start position, but only after the fade-out finished */
  .sprogress[data-state='idle'] .sprogress-bar {
    --sp-value: var(--sp-start, 0.08);
    transition: transform 0s var(--sp-speed);
  }

  /* explicit set() / done(): short, eased hop instead of the long drift */
  .sprogress:is([data-state='active'], [data-state='done']) .sprogress-bar {
    transition-duration: var(--sp-speed);
    transition-timing-function: ease;
  }

  /* forced colors strip backgrounds; paint the bar with the system highlight instead */
  @media (forced-colors: active) {
    .sprogress-bar {
      forced-color-adjust: none;
      background: Highlight;
    }
  }
}