GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS List Style: 9 Marker Design Patterns

Nine list markers: one turns each bullet like a clock as a dial angle stacks per item, one rolls a counter into odometer digits, one glows only on numbers that

Auto-plays · click a tile to jump to its section · all nine in one zip

Every one of the nine listens for exactly one click listener, and each button does a different job: item 01 advances the step, 02 bumps the top value, 03 cycles the wave speed, 04 stops and resumes the march, 05 shifts the start number, 06 cycles the tally, 07 reveals the next depth, 09 submits the whole list, and item 08 alone puts the marker button on each row to hand its state over. The automatic playback runs only while the is-demo class is present and prefers-reduced-motion is false, and the first click strips that class, except item 04 which keeps its reduce check outside the auto-demo condition so the setting is still honored after a visitor takes over. Because ::marker cannot do what these markers do, all nine erase the native marker with list-style: none and rebuild it in the same spot; items 01, 02, and 05 run on ordered lists, the other six on unordered lists.

Anatomy of nine patterns — which calculation moves the marker

The table holds three measured values per item: how much of the frame the moving marker work filled cumulatively, how strong the per-pixel change was on average, and in how many of 23 sampled frames the pixels actually moved.

Item Cumulative area Avg intensity Moving frames
01 2.81% 70.2 17/23
02 0.19% 115.5 19/23
03 0.93% 142.2 23/23
04 0.42% 124.9 23/23
05 2.42% 27.8 20/23
06 1.28% 98.1 15/23
07 0.93% 47.0 10/23
08 1.21% 109.4 11/23
09 0.37% 91.0 10/23

The widest cumulative area belongs to item 01 alone at 2.81%, because each click turns several needles at once in proportion to their numbers and the active highlight stacks on top. The narrowest is item 02 at 0.19%, since the rolling digits repaint only inside narrow columns, and item 07 shows the same 0.93% as item 03 in the table while the measured decimals put 03 (0.931) slightly ahead. Only item 03 crosses an intensity of 140 at 142.2, and the lowest intensity, item 05's 27.8, comes from a glow that spreads as an alpha wash — a low-contrast change that keeps the per-frame pixel difference small. The most moving frames sit with items 03 and 04 at 23 of 23, the fewest with items 07 and 09 at 10 of 23, and that spread is not a flaw: it only reflects how long each pattern rests between steps.

Sorted by the calculation that drives the value, the nine fall into four clusters.

01 · 02 · 09 — a stacked count pushing the marker

Item 01 turns one click into a value stacked on turns[i] so the needle angle grows with the item number, item 02 splits a counter value across digit places with powers of ten, and item 09 stacks the pop moment as i * DELAY_STEP_MS. In all three, what gets carved into the marker is how many times and in what order the input happened, not the input of the current instant.

03 · 04 — flowing time poured in

Item 03 pushes the phase by the item number in sin(t * speed + i * PHASE_RAD) so a shared clock yields different heights, and item 04 integrates the offset at a constant rate over elapsed time with acc += dt/1000 * MARCH_RATE_DEG_S. What they share is that the current frame's time alone decides the value, with no remembered state.

05 · 07 — a predicate doing the switching

Item 05 reruns an isPrime() division check on every number and attaches the is-prime class only to items that pass, and item 07 updates every rail's is-open at once from the single comparison revealed >= depth. Neither consults a stored list; the predicate reruns each time the value changes.

06 · 08 — a proportion measuring the size

Item 06 takes the radius as factor = sqrt(v)/sqrt(max) so the area tracks the value, and item 08 back-solves the stroke with max(1.5, BASE_AREA/size²·3) so a larger marker gets a thinner line. Here the magnitude of a value is converted into the marker's size or weight.

Code per pattern

01Dial-rotating marker — stacking an angle value per item so each bullet turns like a clock

One click stacks onto how many advances that item has taken (turns[i]), and the needle angle multiplies that by the item number (i+1) and DIAL_STEP_DEG (30), so a higher number turns harder on the same click. The auto loop advances one step every STEP_MS (480).

What the numbers say

Item 01 holds the widest cumulative area among the nine, 2.81% at a 70.2 average intensity while moving in 17 of 23 sampled frames — several needles turn at once per click and the active highlight repaints on top, but the pattern rests between STEP_MS steps, which keeps its moving-frame count below the wave and dash items.

Dial-rotating marker — stacking an angle value per item so each bullet turns like a clock
Open in a new tab

01 · Dial-rotating marker — stacking an angle value per item so each bullet turns like a clock — Measured area 2.805% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

DIAL_STEP_DEGrotate$subject-blue
  // Math layer: item i's needle angle stacks how many advances that item has taken (turns[i]) times its own number (i+1).
  // Later items receive a larger angle for the same single advance — an accumulation proportional to the number, not a plain delay.
  var turns = items.map(function () { return 0; });

  function render() {
    items.forEach(function (el, i) {
      var deg = turns[i] * DIAL_STEP_DEG * (i + 1);
      el.style.setProperty('--angle', (deg % 360) + 'deg');
    });
  }

  function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }

02Odometer counter — splitting the counter value into digits so number markers roll

The counter value divides by place = 10^(ODO_DIGITS-1-d) to decompose it per digit, and each strip of 0 through 9 is pulled up with translateY(-digit * DIGIT_H). Each place gets a different transition speed of 260 + d * 220 ms, so the high digit on the left settles first at 260ms while the low digit on the right follows later, slower.

What the numbers say

Item 02 has the narrowest cumulative area among the nine, 0.19%, at a 115.5 average intensity while moving in 19 of 23 sampled frames — the rolling digits repaint continuously, but only inside columns 14px wide, so the strong per-pixel change never spreads across much of the frame.

Odometer counter — splitting the counter value into digits so number markers roll
Open in a new tab

02 · Odometer counter — splitting the counter value into digits so number markers roll — Measured area 0.189% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

ODO_DIGITStranslateY$subject-blue-light
  // Math layer: the counter value is divided by powers of ten and split into digits. Each digit cell carries its own transition speed
  // so the low digit settles first (fast) and the high digit follows (slow) — a per-digit rolling, not a plain opacity fade.
  function setDigits(odo, value) {
    var strips = odo.querySelectorAll('.odo-strip');
    for (var d = 0; d < ODO_DIGITS; d++) {
      var place = Math.pow(10, ODO_DIGITS - 1 - d);
      var digit = Math.floor(value / place) % 10;
      strips[d].style.transform = 'translateY(' + (-digit * DIGIT_H) + 'px)';
    }
  }

  function speedClass(odo) {
    var strips = odo.querySelectorAll('.odo-strip');
    strips.forEach(function (s, d) {
      // d=0 (leftmost, highest place) is the slowest; the farther right (lower place), the faster
      var ms = 260 + d * 220;
      s.style.transitionDuration = ms + 'ms';
    });
  }
  odos.forEach(speedClass);

03Phase-wave marker — shifting a phase value per item so bullets ripple

The bullet's vertical position comes from a sine of amplitude AMPLITUDE_PX (6), with the phase pushed in by the item number (i * PHASE_RAD) so the whole list flows as one wave. The SPEEDS preset [1, 2, 0.5] cycles by click.

What the numbers say

Item 03 carries the highest intensity among the nine, 142.2, at a 0.93% cumulative area while moving in 23 of 23 sampled frames — the sine keeps every bullet drifting nearly every frame, and a full-amplitude vertical shift on a small circle yields the sharpest per-pixel difference of the set.

Phase-wave marker — shifting a phase value per item so bullets ripple
Open in a new tab

03 · Phase-wave marker — shifting a phase value per item so bullets ripple — Measured area 0.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sintranslateY$subject-blue-deep
  // Math layer: each bullet's vertical position is a sine value with the phase pushed in by its item number.
  // t (shared time) still flows, but a different phase yields a different height at the same instant — that is the wave.
  function apply(t) {
    var speed = SPEEDS[speedIdx];
    items.forEach(function (el, i) {
      var phase = i * PHASE_RAD;
      var y = AMPLITUDE_PX * Math.sin(t * speed + phase);
      el.style.setProperty('--y', y.toFixed(2) + 'px');
    });
  }

  function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }

04Marching-dash marker — streaming an offset value so dashes march

Every frame integrates the offset angle at a constant rate with acc += dt/1000 * MARCH_RATE_DEG_S (140), and the dashed border streams along the circle. Stopping and resuming keeps the accumulated value, so the flow never jumps.

What the numbers say

Item 04 moves in 23 of 23 sampled frames at a 124.9 average intensity over a 0.42% cumulative area — the dash rotation repaints a thin ring almost continuously, and because the rotation is a time integral, pausing and resuming never produces a visible jump in the ring.

Marching-dash marker — streaming an offset value so dashes march
Open in a new tab

04 · Marching-dash marker — streaming an offset value so dashes march — Measured area 0.421% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MARCH_RATE_DEG_Sdashed$subject-blue-light
  // Math layer: the offset angle accumulates at a constant rate (MARCH_RATE_DEG_S) over time. Rotating the dashed
  // border by this angle makes the dotted ring appear to flow — a time integral, not a single keyframe loop.
  function apply(offsetDeg) {
    markers.forEach(function (m) { m.style.setProperty('--offset', (offsetDeg % 360) + 'deg'); });
  }

  function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }

  button.addEventListener('click', function () {
    human();
    running = !running;
    button.setAttribute('aria-pressed', String(!running));
    button.textContent = running ? 'Stop the march' : 'Resume the march';
  });

05Prime-glow marker — computing a primality check so only qualifying numbers glow

An isPrime() division check runs on every item number, and only the numbers that pass pulse their glow. When the start number shifts, the check reruns from scratch, and the number itself is redrawn in a ::before from the counter(item) value after list-style: none erases the native numbering.

What the numbers say

Item 05 fills the second-widest cumulative area, 2.42%, at the lowest intensity of the nine, 27.8, while moving in 20 of 23 sampled frames — the glow spreads as an alpha ring, so the area it touches is broad while the per-pixel difference stays gentle in every frame.

Prime-glow marker — computing a primality check so only qualifying numbers glow
Open in a new tab

05 · Prime-glow marker — computing a primality check so only qualifying numbers glow — Measured area 2.419% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

counterbox-shadow$subject-blue-deep
  // Math layer: a primality check runs on each item number — not a precomputed list; it reruns on every pass.
  function isPrime(n) {
    if (n < 2) return false;
    for (var d = 2; d * d <= n; d++) {
      if (n % d === 0) return false;
    }
    return true;
  }

  function recompute() {
    list.setAttribute('start', String(start));
    items.forEach(function (li, i) {
      var n = start + i;
      li.classList.toggle('is-prime', isPrime(n));
    });
  }

06Size-ranked marker — computing marker area in proportion to a value

The marker radius follows factor = sqrt(v)/sqrt(max) so the area stays proportional to the value — a linear proportion would exaggerate big values. The tally cycles through deterministic preset sets every STEP_MS (550), reproducible without Math.random.

What the numbers say

Item 06 covers a 1.28% cumulative area at a 98.1 average intensity while moving in 15 of 23 sampled frames — the scale change is a single transition per preset swap, so its frames cluster around the swap moments rather than spreading across the whole run.

Size-ranked marker — computing marker area in proportion to a value
Open in a new tab

06 · Size-ranked marker — computing marker area in proportion to a value — Measured area 1.283% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

Math.sqrtscale$subject-blue
  // Math layer: the marker radius is proportional to the square root of the value (so area tracks the value) — linear scaling would exaggerate big values.
  function apply(set) {
    var max = Math.max.apply(null, Object.keys(set).map(function (k) { return set[k]; }));
    items.forEach(function (li, i) {
      var key = li.getAttribute('data-key');
      var v = set[key];
      var factor = Math.sqrt(v) / Math.sqrt(max);
      markers[i].style.setProperty('--scale', factor.toFixed(3));
    });
  }

  function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }

  function next() {
    setIdx = (setIdx + 1) % SETS.length;
    apply(SETS[setIdx]);
  }
  button.addEventListener('click', function () { human(); next(); });

07Nested-rail marker — computing indent rails from a depth value

Each nested ul carries a data-depth, and the single comparison revealed >= depth updates every rail's visibility at once — there is no per-rail timer. The rail line itself stretches from scaleY(0) to scaleY(1) through a ::before on the nested ul.

What the numbers say

Item 07 moves in the fewest sampled frames tied with 09, 10 of 23, at a 47.0 average intensity over a 0.93% cumulative area — the reveal is a discrete ladder over MAX_DEPTH (2) levels, so long rests between STEP_MS (750) steps leave most sampled frames unchanged while the rail stretch itself is a full-height repaint.

Nested-rail marker — computing indent rails from a depth value
Open in a new tab

07 · Nested-rail marker — computing indent rails from a depth value — Measured area 0.93% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleYrevealed$subject-blue-light
  // Math layer: whether each rail shows is the result of the comparison revealed depth >= this rail's depth.
  // When the single value (revealed) changes, every shallower rail updates at once — no per-rail timers.
  function apply() {
    tree.style.setProperty('--revealed', String(revealed));
    rails.forEach(function (rail) {
      var d = parseInt(rail.getAttribute('data-depth'), 10);
      rail.classList.toggle('is-open', revealed >= d);
    });
    button.setAttribute('aria-expanded', String(revealed >= MAX_DEPTH));
  }

  function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }

08Hybrid-swap marker — marker kind changing with a state value

Per state (queued, in progress, done), the marker morphs from dot to diamond to check with diameters SIZE_BY_STATE [14, 20, 24]. The stroke thickness is back-solved by the area-preservation form max(1.5, BASE_AREA/size²·3), so a larger marker gets a thinner line and the visual weights interlock.

What the numbers say

Item 08 covers a 1.21% cumulative area at a 109.4 average intensity while moving in 11 of 23 sampled frames — the morph fires only when the auto cursor advances to a row every STEP_MS (700), so the repaint concentrates in short bursts around each state handover.

Hybrid-swap marker — marker kind changing with a state value
Open in a new tab

08 · Hybrid-swap marker — marker kind changing with a state value — Measured area 1.21% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

BASE_AREAthicknessFor$subject-mint
  var wrap = document.getElementById('wrap');
  var list = document.getElementById('list');
  var rows = Array.prototype.slice.call(list.querySelectorAll('li'));
  var isDemo = true;

  var SIZE_BY_STATE = [14, 20, 24]; // queued(dot) < progress(diamond) < done(check) diameter

  // Math layer: thickness is back-solved from an area-preservation form (BASE_AREA / size) — the larger the marker, the thinner
  // the stroke, so the visual weights interlock. Not a list of three arbitrary fixed thicknesses.
  function thicknessFor(size) {
    return Math.max(1.5, BASE_AREA / (size * size) * 3);
  }

  function apply(li) {
    var state = parseInt(li.getAttribute('data-state'), 10);
    var btn = li.querySelector('.marker');
    var size = SIZE_BY_STATE[state];
    var thick = thicknessFor(size);
    btn.style.setProperty('--size', size + 'px');
    btn.style.setProperty('--thick', thick.toFixed(2) + 'px');
    btn.className = 'marker state-' + state;
    btn.setAttribute('aria-label', 'Toggle state: ' + STATES[state]);
    btn.textContent = STATES[state];
  }

09Chain-burst check marker — stacking a delay value so checks pop in cascade

The moment item i pops is stacked as i * DELAY_STEP_MS (110) and carried in the CSS variable --delay straight into animation-delay. JavaScript only toggles the class once; the cascade timing is computed by CSS.

What the numbers say

Item 09 fills a 0.37% cumulative area at a 91.0 average intensity while moving in 10 of 23 sampled frames, tied with 07 for the fewest — the burst wave sweeps once every CYCLE_MS (950), and between waves the checks sit still at their finished pose.

Chain-burst check marker — stacking a delay value so checks pop in cascade
Open in a new tab

09 · Chain-burst check marker — stacking a delay value so checks pop in cascade — Measured area 0.367% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

DELAY_STEP_MSanimation-delay$subject-mint
  // Math layer: item i pops at a delay value stacked i times (i * DELAY_STEP_MS), carried in a CSS variable
  // straight into animation-delay. JavaScript only triggers once (a class toggle); the cascade timing is computed by CSS.
  items.forEach(function (li, i) {
    li.style.setProperty('--delay', (i * DELAY_STEP_MS) + 'ms');
  });

  function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }

  function burst() {
    list.classList.remove('is-bursting');
    // force a reflow so re-adding the same class restarts the animation
    void list.offsetWidth;
    list.classList.add('is-bursting');
  }

  button.addEventListener('click', function () { human(); burst(); });

Where values change markers — angle, digit, phase, and predicate basis

The constants that set one step

Each item's expression is set by named constants — DIAL_STEP_DEG (30) and STEP_MS (480) in 01, ODO_DIGITS (2) and DIGIT_H (18) in 02, WAVE_PHASE_DEG (40) and AMPLITUDE_PX (6) in 03, MARCH_RATE_DEG_S (140) in 04, STEP_MS (900) in 05, R_MAX (15) in 06, MAX_DEPTH (2) in 07, BASE_AREA (400) in 08, DELAY_STEP_MS (110) and CYCLE_MS (950) in 09. When css list style type comes to mind, it usually means swapping the bullet shape through a shape keyword, but in this set the values to reach for are these constants, not a shape list. For reference, item 05's glow pulse period is not one of these JavaScript constants but the scss token $dur-loop (2s).

Neighbor shoots — the other side of the list axis

Opening and closing list items is covered by 9 Accordion List UI Patterns (Copy-Paste), while this post covers only the calculation in front of the item. Pushing and paging items is the subject of 9 Swipe to Delete List UI Patterns (Copy-Paste), and counting numbers upward belongs to 9 CSS Number Counter Animations — Copy-Paste Ready — item 02 here parts ways with a plain count-up by splitting the value into rolling digits. The list syntax itself follows the list-style document and the CSS counters document.

Where it breaks — list-style support, fallback, reduced motion

The properties ::marker accepts directly are only color, content, and font, so rotation, translation, scaling, glow, animation, and child elements are all out of reach. That is why all nine items erase the native marker with list-style: none and rebuild it in the same spot: items 01, 03, 05, and 09 rebuild on the li's ::before and ::after; items 02, 04, 06, and 08 need to hold rolling digit strips and a clickable button, so they insert real DOM elements; item 07 attaches a ::before rail to the nested ul itself. Redrawing the number happens in 05 alone, with counter-reset, counter-increment, and content: counter(item). A visitor who arrives to learn how to remove bullets from a list already meets that first move in all nine items.

The full html, scss, and js of all nine items arrive behind the password 6tdgyv9r in the downloadable zip, ready to open and tune constant by constant.

What reduced motion leaves behind

When the reduce setting is on, item 01 fixes turns at the i+1 end state, 02 fixes the values to [42, 97, 5], 03 pins the phase at π/2, and 04 pins the offset at 90 degrees. Item 05 cuts the pulse and leaves a static 4px ring, 06 fixes the second preset, 07 opens fully, 08 fixes a distinct state per row, and 09 shows the fully checked completion state. Each item also carries a reduced-motion block on the CSS side that cuts every transition.

Putting all nine on one page means nine requestAnimationFrame loops turning at once, which can drag frames on low-end devices — when you mount these in production, keeping just the one marker you need is the safer call.

Three variants and accessibility

Variant What changes What stays
Tighten the dial step 01 DIAL_STEP_DEG 30 → 15 turns stacking, STEP_MS, the item-number product
Add an odometer digit 02 ODO_DIGITS 2 → 3 DIGIT_H, the per-place speed ladder, STEP_MS
Widen the wave phase 03 WAVE_PHASE_DEG 40 → 80 AMPLITUDE_PX, SPEEDS, the item-number push

Lowering 01's DIAL_STEP_DEG from 30 to 15 shrinks the angle each advance adds (turns[i] * DIAL_STEP_DEG * (i+1)), so the needle needs more clicks to reach the next notch — the dial climbs in tighter steps. Raising 02's ODO_DIGITS from 2 to 3 extends the digit decomposition to the hundreds place (Math.floor(value / place) % 10, place = 10^(ODO_DIGITS-1-d)) and rolls a three-digit odometer from 000 to 999 — the range widens tenfold, not twofold. Raising 03's WAVE_PHASE_DEG from 40 to 80 widens the phase gap between adjacent items (i * WAVE_PHASE_DEG * π/180), fitting more wave crests across the list at any instant, so the ripple reads as dense overlapping crests instead of a loose spread.

Decorative markers are hidden from screen readers with aria-hidden on 02's odometer, 04's ring, and 06's chip, while 05's ::before stays exposed because its content is the number itself. Item 08's marker is a real button that announces its state through aria-label, 04 flips aria-pressed on the march toggle, and 07 sets aria-expanded on the reveal button to match the state. Every control is a button, so Enter and Space work as-is, and under reduced motion each item keeps its own final pose without animation, as the previous section described.

FAQ

Can I keep these css list style examples if I flip the list to a horizontal layout?

The markers built on the li's ::before and ::after — items 01, 03, 05, and 09 — follow their items when the list flows horizontally, and the real elements in 02, 04, 06, and 08 are inline so they flow along too. Only item 07's rail is drawn against a vertical stretch, so a horizontal layout needs the rail axis redrawn.

Can these css bullet points style effects be applied to ::marker directly?

No — ::marker accepts only color, content, and font, so rotation, rolling, and glow are impossible there from the start. That is exactly why all nine items erase the marker with list-style: none and rebuild it in the same spot, and if you want a marker that both lives inside the list syntax and moves, this replacement is the starting point.

Which of the nine makes the biggest difference on screen?

By busywork that recomputes every frame, items 03 and 04 lead at 23 of 23; by spread, item 01 leads with a 2.81% cumulative area; by per-pixel difference, item 03 leads at 142.2 intensity. The computation itself is light in all nine — even 05's division check runs few iterations at these number ranges and never becomes a burden.

Sources: MDN — list-style · MDN — CSS counters · MDN — @counter-style · MDN — Web Animations API

Enter the archive password

The password is inside this article. You will find it as you read.