TickerTape
Structured infinite-scroll information strip.
Live demo
01A single CSS keyframe scrolls a doubled-up content track. Each row below mounts a different variant, direction, or pauseOnHover setting. Hover any
strip with pauseOnHover on to freeze it.
Finance Β· stock prices with deltas Β· variant="finance"
Trend chevrons inferred from the delta sign β green up, red down, grey flat. Tabular numerals keep alignment crisp as the strip scrolls.
News Β· headlines with category labels Β· variant="default"
Long-form values work fine β the keyframe is duration-relative so longer items just mean a slower visual cycle. Category labels stay short and uppercase.
Brand logos Β· text-only mark wall Β· variant="minimal"
Brand identity strips don't need deltas. The minimal variant uses a transparent background and hairline borders so it sits naturally inside marketing layouts.
Reverse direction Β· direction="right" Β· variant="finance"
Same finance data, scrolling the other way. Useful for parallel rows where opposing motion creates depth β pair it with a faster forward strip above.
Minimal dashboard Β· pauseOnHover false Β· always scrolling
pauseOnHover=false keeps the strip moving even when the cursor is over it
β useful for purely decorative rows that shouldn't react to interaction above.
Slow scroll Β· variant="default" Β· speed 30 Β· pauseOnHover true
A relaxed pace for editorial copy or status feeds. Hovering pauses via animation-play-state β no JS handler runs in the steady state.
Implementation
02<script lang="ts">
import TickerTape from '$lib/components/TickerTape.svelte';
const items = [
{ label: 'AAPL', value: 192.34, delta: 1.23 },
{ label: 'GOOG', value: 148.07, delta: -0.45 }
];
</script>
β
<TickerTape {items} variant="finance" speed={60} pauseOnHover />TickerTape doubles its content and runs a single CSS keyframe scroll. Trend chevrons are inferred from the delta sign or set explicitly. Pause-on-hover is animation-play-state, so there is zero JS handler in the steady state. prefers-reduced-motion: reduce stops the animation but leaves the strip readable as a static row.
Logic explainer
03What Does It Do? (Plain English)
TickerTape is a horizontal infinite-scroll display of structured data points β Bloomberg's stock crawl, a sports score crawl, an airport status board. Each item is a tuple of {label, value, delta?, trend?} rather than arbitrary content, so colour and glyph cues (β² green for up, βΌ red for down) can be inferred consistently.
The whole thing is one CSS @keyframes animating translateX(-50%) on a track that holds two copies of the items list back-to-back. When the first copy has slid fully off-screen, the duplicate has just slid into the same position β the seam is always off-screen and the loop is invisible. Pause-on-hover toggles animation-play-state only.
How It Works (Pseudo-Code)
state:
none β every behaviour is encoded in CSS
derive (sanitised):
safeVariant = 'default' | 'finance' | 'sports' | 'minimal'
safeDirection = 'left' | 'right'
safeSpeed = clamp(speed, 1..1000) // px/s
trackWidth = items.length * 220 // estimated avg item width
durationSec = trackWidth / safeSpeed
helpers (pure, exported):
pickVariant(name), pickDirection(name) β safe defaults
clampSpeed(n, fallback=60) β 1..1000
inferTrend(item) β 'up' | 'down' | 'flat'
explicit item.trend wins
else: delta > 0 β up, delta < 0 β down, else β flat
formatDelta(delta) β '+1.23%' | '-0.45%' | ''
trendGlyph(trend) β 'β²' | 'βΌ' | 'β¬'
render:
<div class="tickertape" style="--tickertape-duration: {durationSec}s">
<div class="tickertape__track">
{#each [0, 1] as copy} // double the items
{#each items as item}
...label, value, delta...
<span class="tickertape__sep">{separator}</span>
{/each}
{/each}
</div>
</div>
CSS:
@keyframes tickertape-scroll {
from { transform: translate3d(0, 0, 0); }
to { transform: translate3d(-50%, 0, 0); } // exactly half = one full items copy
}
.tickertape__track { animation: tickertape-scroll var(--tickertape-duration) linear infinite; }
.tickertape--right .tickertape__track { animation-direction: reverse; }
.tickertape--pause-on-hover:hover .tickertape__track { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.tickertape__track { animation: none !important; transform: translate3d(0,0,0); }
}The Core Concept: 50% Translate Across a Doubled Track
Naive marquee implementations either re-mount items on a JS interval (jitters) or translateX(-100%) and snap (visible seam). TickerTape duplicates the items list and translates by exactly -50% β the second copy occupies the exact same pixel space the first copy did at 0%, so when the keyframe restarts there is no jump.
items = [A, B, C, D] β 4 items
track DOM = [A, B, C, D, A, B, C, D] β 8 items, 2Γ width
At t=0: track is at 0%
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β A B C D A B C D β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
βββ visible window βββΆ
At t=duration/2: track is at -25%
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β A B C D A B C D β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
βββ visible window βββΆ
At t=duration: track is at -50%
βββββββββββββββββββββββββββββββββββββββββββββββββββ
β A B C D A B C D β
βββββββββββββββββββββββββββββββββββββββββββββββββββ
βββ visible window βββΆ
keyframe restarts at 0% β visible content has not moved
because the second copy of A,B,C,D was sitting at exactly
the position the first copy occupied at t=0.The first copy is fully in view; the duplicate is purely structural to bridge the wrap. aria-hidden="true" is set on the second-copy items so screen readers don't announce them twice.
durationSec = (items.length * 220) / safeSpeed gives a stable px/s velocity regardless of item count. The 220 px estimate is an average β real layout governs visuals; this is just a reasonable knob mapping speed to duration.
CSS Animation Strategy
Three things make the scroll feel right.
One β linear easing. Any other curve would visibly accelerate or decelerate; a ticker should crawl at constant speed.
Two β translate3d (not translateX). Forces a GPU layer; the compositor handles the animation without invalidating layout.
Three β mask-image on the wrapper. A linear gradient fades the leftmost and rightmost 4% of the strip to transparent, so items don't pop into view at the edges:
.tickertape {
mask-image: linear-gradient(to right, transparent 0, #000 4%, #000 96%, transparent 100%);
}Pause-on-hover is animation-play-state: paused β no JS, no event listener. Reverse direction is animation-direction: reverse. Both compose with the same single keyframe.
Performance
- Zero rAF, zero
setInterval, zeroResizeObserver. Every scroll is paint-only and runs on the GPU compositor. - Track renders 2Γ items β that's the only duplication. No 4Γ, no measurement loop, no responsive re-render.
will-change: transformhints the browser to keep the track on its own layer.- Pause cost: a single CSS property flip on
:hover. Resume: same. <TickerTape items={...} variant="finance" />is safe to drop multiple instances on the same page; they all share the GPU compositor pipeline.
State Flow Diagram
[mounted with items.length > 0]
β
β CSS animation starts immediately on render
βΌ
[scrolling] ββ translate3d(0) β translate3d(-50%) ββ loops forever
β
β hover, pauseOnHover = true
βΌ
[paused]
β
β hover ends
βΌ
[scrolling]
prefers-reduced-motion: reduce
β
βΌ
[static] animation: none; track at translate3d(0)
first copy fully readable as a static rowProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items |
TickerItem[] |
[] |
Array of { label, value, delta?, trend?, href? }. Empty array hides the track. |
speed |
number |
60 |
Pixels per second. Clamped to 1..1000. |
direction |
'left' | 'right' |
'left' |
Scroll direction. 'right' flips animation-direction: reverse. |
pauseOnHover |
boolean |
true |
Stop the scroll while the pointer is over the wrapper. |
separator |
string |
'β’' |
Glyph rendered between items. |
variant |
'default' | 'finance' | 'sports' | 'minimal' |
'default' |
Theme tokens β colour grammar, padding, font size. |
class |
string |
'' |
Extra classes on the wrapper. |
aria-label |
string |
'Ticker tape' |
Region label for screen readers. |
TickerItem:
| Field | Type | Notes |
|---|---|---|
label |
string |
Uppercased, muted colour. |
value |
string | number |
Bold, brand-coloured. |
delta |
number (optional) |
Used to infer trend if not set; rendered as +1.23% / -0.45%. |
trend |
'up' | 'down' | 'flat' (optional) |
Explicit override; otherwise inferred from delta sign. |
href |
string (optional) |
Wraps the item in an <a> with focus-visible styles. |
Edge Cases
| Situation | Behaviour |
|---|---|
items is [] |
Track is not rendered; wrapper still exposes role="marquee". |
speed = 0 or non-finite |
clampSpeed returns the fallback 60. |
items very long |
durationSec scales linearly, so px/s stays constant β the scroll just takes longer to repeat. |
Item with delta but no trend |
inferTrend reads delta sign β positive β up, negative β down, zero β flat. |
href on an item |
Renders as <a> with focus-visible outline; AT can Tab to it. The duplicate copy is aria-hidden="true" so it isn't announced or focused twice. |
User has prefers-reduced-motion: reduce |
Animation cancelled; track sits at translate3d(0) β the first copy reads as a static, readable row. |
variant unknown string |
pickVariant falls back to 'default'. |
| Surrogate-pair glyph in label/value (emoji, CJK) | Rendered as a single token β no string-based slicing happens. |
Dependencies
- Svelte 5.x β
$derived,$props. - Zero external dependencies β pure Svelte 5 + scoped CSS.
File Structure
src/lib/components/TickerTape.svelte # implementation
src/lib/components/TickerTape.md # this file (rendered inside ComponentPageShell)
src/lib/components/TickerTape.test.ts # vitest unit tests for the pure helpers
src/routes/tickertape/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
items | TickerItem[] | required | Tuples of { label, value, delta?, trend?, href? }. |
variant | "default" | "finance" | "sports" | "minimal" | "default" | Colour palette and weight preset. |
speed | number | 60 | Scroll speed in px/s, clamped 1β1000. |
direction | "left" | "right" | "left" | Scroll direction. |
pauseOnHover | boolean | true | Pauses via animation-play-state. |
separator | string | "β’" | Glyph between items. |
aria-label | string | 'Ticker tape' | Accessible label for the scrolling region. |