TickerTape

Structured infinite-scroll information strip.

Live demo

01

A 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.

AAPL $187.42 +1.24% MSFT $418.05 -0.43% GOOGL $142.66 +0.87% AMZN $178.30 +3.12% NVDA $889.11 -1.65% TSLA $184.76 +2.04% META $502.85 +0.55% BRK.B $406.25 -0.18% V $272.94 +0.92% JPM $197.50 +1.41%

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.

BREAKING Bank of England holds rates steady at 4.5% TECH OpenAI launches new model with multimodal reasoning MARKETS FTSE 100 closes at record high WORLD EU summit reaches consensus on energy strategy WEATHER Storm warning issued for Scotland SPORT England wins Six Nations opener 24-17 CULTURE Tate Modern announces 2026 retrospective programme

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.

Trusted by ACME Trusted by GLOBEX Trusted by INITECH Trusted by UMBRELLA Trusted by WAYNE Trusted by STARK Trusted by PIED PIPER Trusted by MASSIVE DYN.

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.

AAPL $187.42 +1.24% MSFT $418.05 -0.43% GOOGL $142.66 +0.87% AMZN $178.30 +3.12% NVDA $889.11 -1.65% TSLA $184.76 +2.04% META $502.85 +0.55% BRK.B $406.25 -0.18% V $272.94 +0.92% JPM $197.50 +1.41%

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.

Users 12,847 +2.30% Revenue Β£284k +5.70% MRR Β£94k +1.20% Churn 2.1% -0.40% NPS 64 0.00% Uptime 99.94% +0.02%

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.

BREAKING Bank of England holds rates steady at 4.5% TECH OpenAI launches new model with multimodal reasoning MARKETS FTSE 100 closes at record high WORLD EU summit reaches consensus on energy strategy WEATHER Storm warning issued for Scotland SPORT England wins Six Nations opener 24-17 CULTURE Tate Modern announces 2026 retrospective programme

Implementation

02
TickerTape.svelte
<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

03

What 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, zero ResizeObserver. 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: transform hints 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 row

Props 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 page

API

04
PropTypeDefaultDescription
itemsTickerItem[]requiredTuples of { label, value, delta?, trend?, href? }.
variant"default" | "finance" | "sports" | "minimal""default"Colour palette and weight preset.
speednumber60Scroll speed in px/s, clamped 1–1000.
direction"left" | "right""left"Scroll direction.
pauseOnHoverbooleantruePauses via animation-play-state.
separatorstring"β€’"Glyph between items.
aria-labelstring'Ticker tape'Accessible label for the scrolling region.