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

Stock Ticker UI: 9 Live Price Widgets, No API Key

A stock ticker ui is the strip of numbers in a finance app that keeps changing while you watch it.

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

The nine follow what a price does after it arrives, not what looks impressive in a gallery. It lands in a cell (01), streams past in a strip (02), and drags a trail behind it (03). Then it takes on shapes — candles that close (04), depth bars on both sides of a book (05), a grid of tiles that recolors (06), and one enormous number (07). The last two stop being about the price and start being about trust: a line you set yourself (08), and the readout that says the feed is still breathing (09). Every price here comes from a seeded generator inside the file, which is why the same page replays the same walk for everyone who opens it. If you want to draw a finished dataset once instead of updating it, see CSS Chart Design: 9 SVG Graphs, No Library, and to drag, toggle, and inspect a chart that is already drawn, see Chart UI Design: 9 Interactions You Can Drag. A plain css ticker that only scrolls is 9 CSS Marquee Infinite Loop — No JS, Scroll-Driven; the strip in 02 also rewrites its numbers while it travels. Stacking values by the day rather than by the second is the job of Habit Tracker UI: 9 Parts from Grid to Badge, the other end of this same pillar.

01Price cell that flashes green up, red down

What you see

A new price lands in the cell, the row lights up in a single frame, and the tint drains away while the digits stay locked in their columns. In a watchlist, that flash is the only thing telling you which of the four rows actually moved.

Price cell that flashes green up, red down
Open in a new tab

01 · Price cell that flashes green up, red down — Measured area 19.33% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(1, end)tabular-numssetInterval

Where to look in the code

The preview loop lights three different rows within two seconds, and the render found movement in 9 of the 23 frame pairs, so the board reads as a few cells changing rather than a wall of noise.

// Previous and next value share one grid cell — a cut swap keeps the column steady even when the width changes
.pf__priceWrap, .pf__chgWrap { display: grid; justify-items: end; }
.pf__price, .pf__chg { grid-area: 1 / 1; font-variant-numeric: tabular-nums; }
.pf__chg.is-up { color: $tick-up; }
.pf__chg.is-down { color: $tick-down; }

// The live flash — the is-up / is-down class JS adds turns the background on in one frame and it drains on its own.
// steps(1, end) is what cuts the value swap; the background starts from the lit color and fades out from there
.pf__row.is-up { animation: pfFlashUp 600ms $easing 1; }
.pf__row.is-down { animation: pfFlashDown 600ms $easing 1; }
@keyframes pfFlashUp { from { background-color: $flash-up; } to { background-color: transparent; } }
@keyframes pfFlashDown { from { background-color: $flash-down; } to { background-color: transparent; } }

02Horizontal ticker tape

What you see

Two identical copies of the row travel left, and by the time the seam arrives, the track is already back where it started. Putting a pointer anywhere on the tape holds it still, which is what you want on a lobby monitor someone is trying to read.

Horizontal ticker tape
Open in a new tab

02 · Horizontal ticker tape — Measured area 2.469% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXanimation-play-statemargin-right

How to read the code

A single animation moving in 23 of the 23 frame pairs is all a strip pinned to the top of an app should cost: constant, cheap, and never blinking.

.tt__track {
  display: flex; width: max-content;
  animation-name: ttSlide;
  animation-duration: $duration;
  animation-timing-function: $easing;
  animation-iteration-count: infinite;
}
.tt__item {
  display: inline-flex; align-items: center; flex-shrink: 0; white-space: nowrap;
  margin-right: $sp-6;   // instead of gap on the track — one copy then measures (cell + space) × N exactly
}
@keyframes ttSlide {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}
// Pointer, touch, or keyboard all stop the tape, so words never slide away mid-sentence.
// Nothing but animation-play-state is involved, so no JS has to hold the position
.tt__tape:hover .tt__track,
.tt:focus-within .tt__track { animation-play-state: paused; }

03Sparkline that shifts left as new points arrive

What you see

Each row holds the last 60 ticks, and every new point enters from the right while the whole line steps one notch left. The same shape works for requests-per-second beside each row of a monitoring table.

Sparkline that shifts left as new points arrive
Open in a new tab

03 · Sparkline that shifts left as new points arrive — Measured area 10.465% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

polylinetranslateXpreserveAspectRatio

Where to look in the code

One tick appends one point and moves the track by one unit rather than rebuilding the polyline, which is how 21 animations still add up to only 10.465% cumulative motion over the loop.

// One new value = one point + one step. Not redrawing the other 60 is the whole trick
function tick(r) {
  var mid = (r.lo + r.hi) / 2, span = r.hi - r.lo;
  // A random walk that leans back toward the middle — the line never runs off the scale for good
  var p = Math.round(r.price + (rand() - 0.5) * span * 0.22 - (r.price - mid) * 0.05);
  r.price = p;
  r.line.setAttribute('points', r.line.getAttribute('points') + ' ' + r.count + ',' + toY(r, p).toFixed(2));
  r.count += 1;
  r.shift += 1;
  r.track.style.transform = 'translateX(-' + r.shift + 'px)';
  var chg = (p - r.base) / r.base * 100;
  r.num.textContent = money(p);
  // Direction is never left to color alone — the arrow and the sign always travel with it
  r.chg.textContent = arrow(chg) + ' ' + pct(chg);
  r.chg.className = 'sp__chg ' + (chg >= 0 ? 'is-up' : 'is-down');
  if (r.count > 400) { rebase(r); }
}

04Candle chart whose last bar keeps growing

What you see

The eleven settled candles never move again; only the rightmost one stretches toward its own high and low, and when the period closes, the whole chart cuts one slot to the left. That is the minute-candle area under the period tabs of a trading app.

Candle chart whose last bar keeps growing
Open in a new tab

04 · Candle chart whose last bar keeps growing — Measured area 2.771% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleYtransform-originsteps(1, end)

Where to look in the code

Growth is scaleY anchored at the open-price edge, so the open never drifts while the bar fills out — the render caught movement in 23 of the 23 frame pairs from just 9 animations.

.cl__wick { position: absolute; left: calc(50% - 1px); width: 2px; background: $tick-mute; }
// The wick and the body both grow from the **open-price edge**. Growing from the center would drag the
// already-settled open along with it and the chart would lie — what grows upward is anchored at its bottom
// edge, what grows downward at its top edge
.cl__wick--up { transform-origin: 50% 100%; }
.cl__wick--dn { transform-origin: 50% 0; }
.cl__body { box-sizing: border-box; position: absolute; left: 18%; right: 18%; border-radius: $r-xs; }
// Direction is not left to color alone — an up bar is a hollow body, a down bar a filled one
.cl__body.is-up { border: 2px solid $color; background: transparent; transform-origin: 50% 100%; }
.cl__body.is-down { border: 2px solid $tick-down; background: $tick-down; transform-origin: 50% 0; }
@keyframes clShift {
  0%, 70% { transform: translateX(0); }
  72%, 92% { transform: translateX(calc(-100% / 13)); }
  94%, 100% { transform: translateX(0); }
}

05Order book whose bid and ask depth breathes

What you see

Every row shows how much has piled up to that price, asks reach in from the right and bids from the left, so the two sides swell and shrink as sizes change. It is the book beside a trading screen, or an internal tool someone checks liquidity with.

Order book whose bid and ask depth breathes
Open in a new tab

05 · Order book whose bid and ask depth breathes — Measured area 5.564% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · See the password

scaleXtransform-origintransition

How to read the code

Updates touch nothing but scaleX, and transition carries the new length, which held the loop to 5.564% cumulative motion across 18 of the 23 frame pairs.

// The bar for everything stacked up to that row. It is absolutely placed so it never eats the text cell's
// space, and every update lets transition carry the length instead of snapping it
.ob__bar { position: absolute; top: 1px; bottom: 1px; left: 0; right: 0; transition: transform $dur-quick $ease-out; }
// Asks reach in from the right edge, bids out from the left — the two transform-origins are opposites.
// Growing from the center would move both ends and lose the "which side is this stacking from" cue.
// The end corners are rounded the opposite way too, so the side survives without color
.ob__bar--ask { transform-origin: 100% 50%; background: rgba(255, 122, 94, .3); border-radius: $r-xs 0 0 $r-xs; }
.ob__bar--bid { transform-origin: 0 50%; background: rgba(61, 220, 151, .3); border-radius: 0 $r-xs $r-xs 0; }
@keyframes obBar3 {
  0%, 16% { transform: scaleX(.66); }
  28%, 50% { transform: scaleX(.79); }
  62%, 82% { transform: scaleX(.59); }
  94%, 100% { transform: scaleX(.66); }
}

06Grid heatmap that recolors by percent change

What you see

Twelve equal tiles recolor by percent change, three at a time, and the color slides toward the new value instead of snapping to it. This is a whole-portfolio glance or a sector summary widget, and nothing more.

Grid heatmap that recolors by percent change
Open in a new tab

06 · Grid heatmap that recolors by percent change — Measured area 29.14% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

@propertycolor-mixgrid-template-columns

Where to look in the code

Registering --chg with @property is the entire reason color-mix can interpolate, and it makes this the busiest of the nine at 36 animations and 29.14% cumulative motion.

// A percent change has to be a number to interpolate — unregistered, keyframes and transitions jump instead.
// Registered with inherits: false, so the value must sit on the tile itself (on the parent grid it does nothing)
@property --chg { syntax: "<number>"; inherits: false; initial-value: 0; }
.hm__tile {
  --chg: calc(var(--va) + (var(--vb) - var(--va)) * var(--p));
  // Near zero the panel color stands. Upward mixes in the up color, downward the down color — 11% per percent, capped at 50%
  --up-mix: clamp(0%, calc(var(--chg) * 11%), #{$mix-cap});
  --down-mix: clamp(0%, calc(var(--chg) * -11%), #{$mix-cap});
  background-color: color-mix(in oklab, $tick-down var(--down-mix),
                    color-mix(in oklab, $tick-up var(--up-mix), $tick-panel));
  // When a live value changes the color follows instead of cutting — a registered number property interpolates
  transition: --chg $dur-base $easing;
}

07Big number board with rolling digits

What you see

An unrealized P/L figure rolls one digit column after another while the sign, the thousands separator, and the decimal point stay nailed down. Columns depart 3% apart starting from the right, so most of the number is still readable mid-roll.

Big number board with rolling digits
Open in a new tab

07 · Big number board with rolling digits — Measured area 3.781% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateYtabular-numsoverflow

Where to look in the code

Each column is a 0-to-9 strip pulled up by translateY, and the registered --d is what lets 9 roll into 0 instead of jumping there — movement showed in 12 of the 23 frame pairs.

// The roll position has to be a number to interpolate — unregistered, 9 jumps to 0 instead of rolling.
// Registered with inherits: false, so the value must sit on the digit column itself (on the parent it does nothing)
@property --d { syntax: "<number>"; inherits: false; initial-value: 0; }
// One cell's height. The roll distance is a multiple of it — glyph height and cell height have to match
$cell: 1.15em;
// A digit column — 0 through 9 stacked vertically and pulled up. The column clips whatever overflows
.od__digit { display: block; overflow: hidden; width: .64em; height: $cell; }
.od__strip {
  display: block;
  transform: translateY(calc(var(--d) * -#{$cell}));
  // This is what rolls when a live value changes. A registered number property carries 9 into 0 as well
  transition: --d $dur-enter $easing;
}
// The separator and the decimal point never roll — they stay put so the eye keeps track of the grouping
.od__fixed { display: block; width: .3em; height: $cell; line-height: 1.15; }
// The rightmost column leaves first and each column to its left follows 3% later
@keyframes odRoll5 { 0%, 24% { --d: 5; } 30%, 60% { --d: 5; } 66%, 100% { --d: 5; } }
@keyframes odRoll4 { 0%, 27% { --d: 2; } 33%, 63% { --d: 6; } 69%, 100% { --d: 2; } }
@keyframes odRoll3 { 0%, 30% { --d: 9; } 36%, 66% { --d: 6; } 72%, 100% { --d: 9; } }
@keyframes odRoll2 { 0%, 33% { --d: 6; } 39%, 69% { --d: 7; } 75%, 100% { --d: 6; } }
@keyframes odRoll1 { 0%, 36% { --d: 3; } 42%, 72% { --d: 7; } 78%, 100% { --d: 3; } }
@keyframes odRoll0 { 0%, 39% { --d: 1; } 45%, 75% { --d: 2; } 81%, 100% { --d: 1; } }

08Threshold badge that fires when the line is crossed

What you see

The badge appears on the one frame where the price crosses the line you set, and it stays quiet while the price wanders around on the same side of it. Slider, buttons, and keyboard all move that line, which is how a price-alert settings screen behaves before anyone wires up a notification.

Threshold badge that fires when the line is crossed
Open in a new tab

08 · Threshold badge that fires when the line is crossed — Measured area 2.406% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-livebox-shadowsteps(1, end)

Where to look in the code

Three seconds after a pointer woke this item, the probe found two text cells and one inline style rewritten (run/296/_갱신실측.json), and the alert sentence — not the price column — is the one carrying aria-live="polite".

function paint() {
  var th = Number(rangeEl.value);
  var chg = (price - BASE) / BASE * 100;
  priceEl.textContent = money(price);
  dirEl.textContent = arrow(chg) + ' ' + pct(chg);
  dirEl.className = 'at__dir at__dir--next ' + (chg >= 0 ? 'is-up' : 'is-down');
  thEl.textContent = money(th);
  alertValEl.textContent = money(th);
  // The holder is as wide as the track, so a % in translateX is a position on the track (the dot's own width is not the ruler)
  dotEl.style.transform = 'translateX(' + posOf(price).toFixed(1) + '%)';
  lineEl.style.transform = 'translateX(' + posOf(th).toFixed(1) + '%)';
  // A crossing fires once, and only when the previous state and this one land on opposite sides of the line.
  // While the value stays on the same side it never rings again, however far it travels
  var over = price >= th;
  if (wasOver === false && over) { fire(); }
  wasOver = over;
  root.classList.toggle('is-over', over);
}

09Connection state and update lag readout

What you see

A dot breathes, a counter says how many seconds have passed since the last value, and after a long enough silence the line walks itself from live to disconnected to reconnecting. That is the status corner of a live screen, or the banner that admits the socket went away.

Connection state and update lag readout
Open in a new tab

09 · Connection state and update lag readout — Measured area 0.926% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

visibilityStateanimation-delayopacity

Where to look in the code

The timer returns early whenever document.visibilityState is hidden, and the same probe watched four text cells change in three seconds while the loop came in at 0.926% cumulative motion — the quietest of the nine.

function step() {
  if (root.classList.contains('is-demo')) { return; }
  // If the tab is hidden, don't count — a lag number on a screen nobody is looking at means nothing, and
  // this keeps a backlog from bursting all at once on return (document.visibilityState)
  if (document.visibilityState === 'hidden') { return; }
  var since = Date.now() - lastAt;
  if (phase === LIVE) {
    // After five values it acts out a feed that stopped — once the lag piles up the state moves on by itself
    if (ticks > 0 && ticks % 5 === 0) {
      if (since > 2600) { phase = LOST; setState(LOST); }
    } else if (since > 1400) { feed(); }
  } else if (phase === LOST) {
    if (since > 4200) { phase = RETRY; setState(RETRY); }
  } else if (since > 6000) {
    phase = LIVE; setState(LIVE); feed();
  }
  showLag();
}

Where it breaks — the trap

Every stock price ui meets the same wall twice: the number is the easy part, and the box around it is not. The tape in 02 started with width: 100% on its panel, and since overflow: hidden only clips what you see and never shrinks the element's max-content, a 480px stage spread to 1,047px and grew a scrollbar; pinning the panel to min(340px, 84vw) ended it. The candle tooltip in 04 hard-coded left in pixels, and the render harness narrows the viewport from 480 to 320 without reloading, so the stale pixel value pushed the tooltip off the board until it was re-measured on resize behind a max-width: 100% guard. Color is the second wall. The first down color was $error (#c2361a), which measures 2.96:1 on the #231f28 panel and fails body text outright; #ff7a5e reads 6.31:1 on that same surface, and the up green #3ddc97 reads 9.15:1 there (run/296/_대비실측.json). The heatmap hits the same ceiling from the other side: mixing 55% toward the up color leaves cream text at 4.35:1, so the mix is capped at 50%, where up sits at 4.93:1 and down at 6.16:1 and one cream text color works on every tile. The odometer failed differently — with every digit column rolling on the same frame, the poster image blurred as a whole, and staggering the columns 3% apart from the right keeps most of the number legible in any single frame.

What broke Measured Fix
width: 100% on the tape panel 480px stage spread to 1,047px Pin it to min(340px, 84vw)
Tooltip left hard-coded in pixels Fell outside the board at 320px Re-measure on resize, max-width: 100%
Tile mix at 55% toward up Cream text at 4.35:1, under 4.5:1 Cap the mix at 50%, one cream text color
Every digit column rolling at once The poster frame blurred entirely Stagger the columns 3% apart from the right
$error as the down color 2.96:1 on the #231f28 panel #ff7a5e, 6.31:1 on the same panel

Losing the reader's eye when several rows refresh at once is not unique to a price board — the same shape turns up in 9 Activity Feed UI Widgets.

The password for the archive is m5tz6sq7, and opening it gives you all nine in two matching editions — a plain HTML file you drop into a browser and a React component you paste into an app.

Accessibility (reduced-motion)

None of the nine leaves direction to color: every number carries a ▲ or ▼ and a + or − beside it, 08 spells out whether the price is above or below your line in words, and 09 keeps a state word next to the breathing dot. Only one line in the whole set is announced — the alert sentence in 08 has aria-live="polite", and the updating price columns deliberately do not, because a screen reader would otherwise read the market out loud forever (MDN on aria-live). Blinking is kept low by design: the preview loop of 01 lights up three times in two seconds, which is 1.5 per second, 08 and 09 flash twice per loop, 1 per second, and 02 and 03 never flash at all — WCAG 2.3.1 asks for three or fewer per second. Under prefers-reduced-motion: reduce each item drops its motion and keeps its data: the flash in 01 is off while values still change, the tape in 02 parks at translateX(0) and only its numbers move, the sparkline in 03 stops shifting and shows the latest value, the live candle in 04 stands fully grown with no closing cut, the depth bars in 05 lose their transition but not their lengths, the heatmap in 06 and the odometer in 07 stop interpolating color and rolling digits, 08 holds the crossed state with the alert text intact, and 09 stops breathing while the state word and the lag number stay.

FAQ

Can I ship this stock ticker ui design with a real feed?

Yes, and the swap is smaller than it looks. Every item reads numbers from one place — a seeded generator that ticks on setInterval — so replacing that function with a socket message handler or a poll leaves the markup and the CSS untouched. Keep the update rhythm deliberate, though: the steady loops here run between 260ms and 900ms per change on purpose, and a feed that pushes hundreds of messages per second should be coalesced before it reaches the DOM.

Do the flashing colors break any accessibility rule?

Not as measured. The busiest item flashes 1.5 times per second, the rest are at 1 or zero, and the threshold is three per second, so there is headroom even if you shorten the interval. What does need care is the color pairing — check any tint you add against the panel it sits on rather than against the page background, since the same green reads 9.15:1 on #231f28 and fails completely on a white or yellow stage. How the alert itself stacks and leaves once a threshold fires is covered in Toast Notification UI: 9 Stacking Patterns.

Will tabular-nums and the Page Visibility API work in old browsers?

Both are effectively universal now. As measured on caniuse in September 2026, font-variant-numeric covers 96.75% globally — Chrome 52+, Edge 79+, Firefox 34+, Safari 9.1+, iOS Safari 9.3+ — and document.visibilityState covers 97.26% from Chrome 14+, Edge 12+, Firefox 10+, and Safari 6.1+ (MDN on visibilityState). Neither works in Opera Mini, and old Internet Explorer never got font-variant-numeric; in both cases the digits simply keep their normal spacing and the timer keeps counting, which costs you a little wobble rather than the widget.

Enter the archive password

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