Marquee

Infinite scrolling content rail with pause-on-hover.

Live demo

01

Static Marquee Β· testimonials

Hover to pause. Default 60 s loop.

πŸ‘©β€πŸ’Ό

"These components saved us weeks of development time. Absolutely brilliant!"

Sarah Chen Product Designer at TechCorp
πŸ‘¨β€πŸ’»

"Clean, performant, and beautifully designed. Exactly what we needed."

James Rodriguez Engineering Lead at StartupXYZ
πŸ‘©β€πŸŽ¨

"The TypeScript support and documentation are top-notch. Highly recommend!"

Emily Watson Frontend Developer at DesignStudio
πŸ‘¨β€πŸ’Ό

"Production-ready components that just work. Our team loves them!"

Michael Park CTO at InnovateLabs
πŸ‘©β€πŸ”¬

"Responsive, accessible, and easy to customise. Perfect for our projects."

Lisa Thompson UI Engineer at CloudSystems
πŸ‘©β€πŸ’Ό

"These components saved us weeks of development time. Absolutely brilliant!"

Sarah Chen Product Designer at TechCorp
πŸ‘¨β€πŸ’»

"Clean, performant, and beautifully designed. Exactly what we needed."

James Rodriguez Engineering Lead at StartupXYZ
πŸ‘©β€πŸŽ¨

"The TypeScript support and documentation are top-notch. Highly recommend!"

Emily Watson Frontend Developer at DesignStudio
πŸ‘¨β€πŸ’Ό

"Production-ready components that just work. Our team loves them!"

Michael Park CTO at InnovateLabs
πŸ‘©β€πŸ”¬

"Responsive, accessible, and easy to customise. Perfect for our projects."

Lisa Thompson UI Engineer at CloudSystems
πŸ‘©β€πŸ’Ό

"These components saved us weeks of development time. Absolutely brilliant!"

Sarah Chen Product Designer at TechCorp
πŸ‘¨β€πŸ’»

"Clean, performant, and beautifully designed. Exactly what we needed."

James Rodriguez Engineering Lead at StartupXYZ
πŸ‘©β€πŸŽ¨

"The TypeScript support and documentation are top-notch. Highly recommend!"

Emily Watson Frontend Developer at DesignStudio
πŸ‘¨β€πŸ’Ό

"Production-ready components that just work. Our team loves them!"

Michael Park CTO at InnovateLabs
πŸ‘©β€πŸ”¬

"Responsive, accessible, and easy to customise. Perfect for our projects."

Lisa Thompson UI Engineer at CloudSystems
πŸ‘©β€πŸ’Ό

"These components saved us weeks of development time. Absolutely brilliant!"

Sarah Chen Product Designer at TechCorp
πŸ‘¨β€πŸ’»

"Clean, performant, and beautifully designed. Exactly what we needed."

James Rodriguez Engineering Lead at StartupXYZ
πŸ‘©β€πŸŽ¨

"The TypeScript support and documentation are top-notch. Highly recommend!"

Emily Watson Frontend Developer at DesignStudio
πŸ‘¨β€πŸ’Ό

"Production-ready components that just work. Our team loves them!"

Michael Park CTO at InnovateLabs
πŸ‘©β€πŸ”¬

"Responsive, accessible, and easy to customise. Perfect for our projects."

Lisa Thompson UI Engineer at CloudSystems

Static Marquee Β· company logos

Faster 20 s loop.

Static Marquee Β· reverse direction

reverse=true flips the scroll.

⚑ Lightning Fast
πŸ“± Mobile Responsive
β™Ώ Fully Accessible
🎨 Customisable
πŸš€ Zero Dependencies
πŸ“ TypeScript Support
⚑ Lightning Fast
πŸ“± Mobile Responsive
β™Ώ Fully Accessible
🎨 Customisable
πŸš€ Zero Dependencies
πŸ“ TypeScript Support
⚑ Lightning Fast
πŸ“± Mobile Responsive
β™Ώ Fully Accessible
🎨 Customisable
πŸš€ Zero Dependencies
πŸ“ TypeScript Support
⚑ Lightning Fast
πŸ“± Mobile Responsive
β™Ώ Fully Accessible
🎨 Customisable
πŸš€ Zero Dependencies
πŸ“ TypeScript Support

Draggable Marquee Β· products

Grab and fling. Momentum decays after release.

Draggable Marquee Β· testimonials

Slow loop, drag to read at your own pace.

Draggable Marquee Β· features

Fast 20 s loop with reverse direction.

Implementation

02
Marquee.svelte
<script lang="ts">
  import Marquee from '$lib/components/Marquee.svelte';
</script>
​
<Marquee duration={30} pauseOnHover>
  {#snippet children()}
    <img src="/logo1.svg" alt="Partner 1" />
    <img src="/logo2.svg" alt="Partner 2" />
  {/snippet}
</Marquee>

Marquee.svelte duplicates its content and runs a CSS keyframe scroll, pausing via animation-play-state when pauseOnHover is set. MarqueeDraggable.svelte adds a pointerdown/pointermove/pointerup pipeline with momentum decay, letting users grab the rail and fling it. The demo loads testimonials from Neon with graceful fallback so the page works whether DATABASE_URL is configured or not.

Logic explainer

03

What Does It Do? (Plain English)

Marquee creates an endlessly scrolling horizontal (or vertical) strip of arbitrary content β€” logos, testimonials, headlines, cards. Pure CSS keyframe animation translates a row of duplicated children across the visible window forever; pause-on-hover is animation-play-state: paused; reverse direction is animation-direction: reverse. Unlike TickerTape (which is structured items only), Marquee is a content-agnostic Snippet wrapper β€” drop anything inside.

Think of it as a sushi conveyor belt: dishes (your children) keep gliding past the visible window, and you can hover to peek at one without it sliding away.

How It Works (Pseudo-Code)

state:
  containerEl   = bound DOM ref (the visible window)
  contentEl     = bound DOM ref (the FIRST child copy, used for measurement)
  actualRepeat  = computed copy count, starts at the `repeat` prop floor

derive:
  none β€” every visual is encoded in CSS classes

on mount:
  requestAnimationFrame:
    calculateRepeat()         // first measurement after layout settles
  ResizeObserver(containerEl):
    on resize β†’ calculateRepeat()  // container can grow on viewport change
  cleanup: resizeObserver.disconnect()

calculateRepeat():
  if !containerEl or !contentEl: bail
  containerSize = vertical ? containerEl.offsetHeight : containerEl.offsetWidth
  contentSize   = vertical ? contentEl.offsetHeight   : contentEl.offsetWidth
  if contentSize <= 0: bail
  // Need 3Γ— container worth of content (one going out, one in view, one coming in),
  // plus the gap between copies, rounded up.
  minCopies     = ceil((containerSize * 3) / contentSize) + 1
  actualRepeat  = max(4, minCopies, repeat)

render:
  <div class="group flex overflow-hidden" style="--duration:{duration}s; --delay:-{duration/2}s">
    <div bind:this={contentEl} class="animate-marquee ...">  <!-- copy 0: measured -->
      {@render children?.()}
    </div>
    {#each Array.from({ length: actualRepeat - 1 }) as _, i (i)}
      <div class="animate-marquee ...">                       <!-- copies 1..n-1 -->
        {@render children?.()}
      </div>
    {/each}
  </div>

CSS (Tailwind config):
  @keyframes marquee {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - var(--gap))); }
  }
  .animate-marquee { animation: marquee var(--duration) linear infinite; }
  .group:hover .animate-marquee { animation-play-state: paused; }     // when pauseOnHover
  .animate-marquee[direction:reverse]                                 // when reverse

The Core Concept: Dynamic Repeat Count for Seamless Loop

A single-copy marquee jumps when the keyframe restarts (the strip teleports back to 0 and the user sees the gap). A two-copy marquee mostly works for content wider than the container, but breaks for short content where the gap is visible while one copy is exiting. The library variants like SwiftUI's marquee solve this by hardcoding 4 copies; that's wasteful for long content and still wrong for short content if the container is wide enough.

Marquee measures both at runtime and picks an exact count:

  containerSize = 1200px       (visible window)
  contentSize   =  300px       (one copy of children)

  We need enough copies that AS the strip animates from 0 β†’ -100% - gap,
  the visible window always shows live content. That requires:
    one copy worth going OUT on the leading edge
  + one copy worth IN VIEW
  + one copy worth COMING IN on the trailing edge
  = 3Γ— containerSize worth of strip.

  minCopies = ceil((1200 * 3) / 300) + 1 = 13
  actualRepeat = max(4, 13, repeat) = 13

  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ [chld] [chld] [chld] [chld] [chld] [chld]  β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚             β”‚             β”‚
       β”‚             β”‚             └── coming IN (trailing buffer)
       β”‚             └────────────── visible window
       └──────────────────────────── exiting OUT (leading buffer)
  Short content (single 80px logo) in a 1600px container:
  minCopies = ceil(1600 * 3 / 80) + 1 = 61
  actualRepeat = 61   β€” many tiny copies, never any visible gap

  Long content (1200px wide testimonial card) in a 320px container:
  minCopies = ceil(320 * 3 / 1200) + 1 = 2
  actualRepeat = max(4, 2, 4) = 4   β€” `repeat` floor wins

The first copy is bound (contentEl) and the rest are spread by {#each} so they all share the same animation, gap, and direction modifiers. The --delay: -{duration/2}s negative offset starts copies mid-animation so they're already in motion at mount, hiding the cold-start jump that some browsers show on the very first keyframe.

CSS Animation Strategy

@keyframes marquee translates 0 β†’ calc(-100% - var(--gap)) β€” exactly one content width plus the gap, so when the keyframe restarts the next copy lines up perfectly with where the first copy started. linear easing keeps velocity constant; any other curve would visibly accelerate or decelerate.

pause-on-hover is implemented through Tailwind's group:hover selector: the wrapper carries class="group", each child carries class="group-hover:[animation-play-state:paused]". No JS event listener β€” the browser handles it. [animation-direction:reverse] flips direction on reverse=true, again purely CSS.

For reduced-motion users, Tailwind's motion-reduce: variants (or the browser's prefers-reduced-motion: reduce media query in the keyframes config) cancel the animation, leaving the strip statically positioned at translate(0).

Performance

  • One CSS keyframe animation per copy. All copies animate identically and the GPU compositor batches them.
  • transform translates are paint-free on the layout thread; the animation never invalidates layout.
  • ResizeObserver only fires on container size change. Quiet pages do nothing after mount.
  • actualRepeat is the minimum copies needed; we don't over-render. The 4-copy floor is a safety net for cases where measurement returns 0 (e.g. content not yet laid out).
  • Hover pause is free β€” a single CSS property flip via :hover.

State Flow Diagram

  [mounted]
       β”‚
       β”‚ rAF β†’ calculateRepeat()
       β”‚ actualRepeat = max(4, minCopies, repeat)
       β–Ό
  [scrolling]   each copy animates 0 β†’ calc(-100% - gap), looping forever
       β”‚
       β”‚ pauseOnHover && hover
       β–Ό
  [paused]   animation-play-state: paused
       β”‚
       β”‚ hover ends
       β–Ό
  [scrolling]
       β”‚
       β”‚ ResizeObserver fires (container resized)
       β–Ό
  recalc actualRepeat β†’ re-render with new copy count
       β”‚
       β–Ό
  [scrolling]   continues from current frame on the new strip

  prefers-reduced-motion: reduce
        β”‚
        β–Ό
   [static]  animation suppressed, strip at translate(0)

Props Reference

Prop Type Default Description
pauseOnHover boolean false Pause the scroll while the pointer is over the wrapper.
vertical boolean false Scroll vertically (translateY) instead of horizontally.
repeat number 4 Minimum number of copies. Acts as a safety floor; the runtime calculation may render more.
reverse boolean false Reverse scroll direction (animation-direction: reverse).
duration number 40 Seconds for one full cycle. Lower = faster.
class string '' Extra classes on the wrapper.
children Snippet β€” Content snippet rendered inside each copy.

Edge Cases

Situation Behaviour
Content is wider than the container minCopies from the formula may be < 4; the max(4, ...) floor keeps the animation seamless.
Container resizes (viewport, devtools docked) ResizeObserver fires and calculateRepeat() recomputes; actualRepeat updates and Svelte re-renders the each-block.
Content has zero size at mount (font not loaded yet) calculateRepeat bails (contentSize <= 0); actualRepeat stays at the prop default of 4. Subsequent ResizeObserver calls fix it once the layout settles.
vertical=true with a fixed-height container Same logic, just measuring offsetHeight. The marquee-vertical keyframe animates translateY instead.
pauseOnHover=true on a touch-only device :hover on iOS triggers on first tap; the user gets one pause-tap, then a second tap proceeds normally.
User has prefers-reduced-motion: reduce Combined with Tailwind motion-reduce: variants, the animation can be neutralised; the strip sits motionless.
Snippet renders nothing First copy has offsetWidth: 0; calculation bails; the strip renders 4 empty copies and animates them invisibly.
repeat set very high (e.g. 100) Acts as a floor; actualRepeat = max(100, minCopies, 4). DOM cost scales linearly.

Dependencies

  • Svelte 5.x β€” $state, $props, snippet (children) syntax, onMount.
  • TailwindCSS β€” provides animate-marquee, animate-marquee-vertical keyframes from tailwind.config.js.
  • $lib/utils β€” cn helper for class merging.
  • ResizeObserver (native) β€” for runtime copy-count recalculation.
  • Zero external animation libraries.

File Structure

src/lib/components/Marquee.svelte    # implementation
src/lib/components/Marquee.md        # this file (rendered inside ComponentPageShell)
src/lib/components/Marquee.test.ts   # vitest unit tests
src/routes/marquee/+page.svelte      # demo page
tailwind.config.js                   # marquee + marquee-vertical keyframes

API

04
PropTypeDefaultDescription
durationnumber40Loop duration in seconds.
reversebooleanfalseFlip scroll direction.
verticalbooleanfalseScroll vertically instead of horizontally.
repeatnumber4How many copies of the children to render β€” bump up for very short content.
pauseOnHoverbooleanfalseStatic variant only β€” pause on hover.
dragEnabledbooleantrueDraggable variant only β€” enable click-and-drag.
dragMomentumbooleantrueDraggable variant only β€” apply momentum after release.