apx-dsv0.1Local renderer · live
K
Foundations4
  • Getting started
  • Theming
  • Templates
  • Icons
  • Avatar
  • Badge
  • DataGrid
  • Scheduler
  • Stat
  • Table
  • Timeline
  • TreeView
  • Accordion
  • Alert
  • EmptyState
  • Progress
  • Skeleton
  • Spinner
  • SplashScreen
  • Toast
  • ColorPicker
  • FileUpload
  • Form
  • Rating
  • TagsInput
  • Combobox
  • Field
  • Select
  • Toggle
  • Button
  • Calendar
  • Checkbox
  • DatePicker
  • Input
  • NumberInput
  • Radio
  • Slider
  • Switch
  • Textarea
  • AppShell
  • Div
  • Divider
  • Marquee
  • Parallax
  • Reveal
  • Section
  • SectionHeading
  • Sidebar
  • Stack
  • Surface
  • Typography
  • Image
  • Breadcrumbs
  • Carousel
  • NavigationMenu
  • Pagination
  • Stepper
  • Tabs
  • Toolbar
  • CommandPalette
  • Confirm
  • Drawer
  • HoverCard
  • Menu
  • Modal
  • Popover
  • Tooltip
  • Icon
  • Card
  • PricingCard
66 componentsapx-ds/renderer
Marquee
Variant↳ other

Layout

Marquee

Continuously scrolling band for logo tickers, category rails and press strips. Pure CSS — the track renders its children twice and translates by one copy on a linear infinite keyframe, so there is no frame loop or scroll listener. Four directions, three named speeds, optional pause-on-hover and edge fade. Under prefers-reduced-motion it becomes a native scroll region rather than a stopped animation, so every item stays reachable.

Marquee

A continuously scrolling band. The canonical use is a logo ticker under a hero, but it's the same primitive behind category rails, press strips and testimonial ribbons.

Overview — a slow logo band and a fast, hover-pausing tag rail

Loading preview…
Overview.tsx
tsx
import { Marquee } from 'apx-ds';

<Marquee speed="slow" gap={16} fade>
  {brands.map((b) => <BrandLogo key={b.id} {...b} />)}
</Marquee>
import { Marquee } from 'apx-ds';

<Marquee speed="slow" gap={16} fade>
  {brands.map((b) => <BrandLogo key={b.id} {...b} />)}
</Marquee>

Why it's a DS component

Every template that has needed a logo band so far has hand-rolled it as a static flex row, because there was nowhere to put the alternative. That's three problems, none of which is about the visual:

  • The seamless loop is arithmetic, not styling — get the offset wrong by half a gap and the track hitches once per cycle. It should be solved once.
  • The duplicate copy the loop requires is an accessibility hazard: done naively it announces every logo twice and drops a second, unreachable set of links into the tab order.
  • The reduced-motion branch is the part that gets skipped, and skipping it is worse here than almost anywhere else — see below.

How the loop works

The track renders its children twice and translates by exactly one copy on a linear infinite keyframe. The instant copy A has fully exited, copy B sits pixel-for-pixel where A began, so the restart is invisible.

The animation itself is pure CSS — no requestAnimationFrame, no scroll listener, no per-frame layout. A marquee costs the same whether it's onscreen or three sections down.

linear is not a stylistic choice: any easing accelerates and decelerates within each cycle, which makes the seam legible as a stutter.

speed is a rate, not a duration

This is the part most marquee implementations get wrong, and it's invisible until you have two of them.

A seconds-per-cycle duration is the intuitive knob and the wrong one. Give a 6-logo band and a 20-logo band the same 40-second cycle and they travel at visibly different speeds — the longer track has to cover three times the distance in the same time. Authors then hand-tune a duration per band, and the site's tempo drifts apart.

So speed resolves from ambientMotion.speed in @apx-ui/tokens, which is a scale in pixels per second. That quantity is content-independent, so it's the one that can meaningfully be a token: every band on the site moves at the same physical speed no matter how many items it holds.

The component measures its own track and derives the duration:

tsx
seconds = (one group's width + the gap after it) ÷ pixels-per-second
seconds = (one group's width + the gap after it) ÷ pixels-per-second

Measured, because CSS can't do it: deriving a duration from a distance needs a length ÷ length division, which calc() doesn't support. The measurement runs under a ResizeObserver, not once on mount — web fonts swap in and reflow the items, images load and change their intrinsic size, and the container itself changes on resize. A one-shot read captures whichever of those hadn't happened yet and leaves the band at the wrong tempo for the rest of the session.

Before the first measurement lands (SSR, first paint) the track falls back to a 50% translate at the tempo a 1440px band would get. Approximately right, and far better than a band sitting frozen until JS arrives.

Reduced motion

Under prefers-reduced-motion: reduce a Marquee does not simply stop animating.

A stopped marquee is a trap. The items past the container edge become unreachable, so the content is strictly worse than if it had never scrolled at all. Instead the component drops the clone and turns the container into a native scroll region — every item stays reachable by wheel, drag and keyboard, and nothing moves on its own.

That's why this branch changes the markup, not just a class. <ExampleBlock for="ReducedMotion" /> below shows both presentations side by side.

Both presentations — animated, and the reduced-motion scroll region

Loading preview…
ReducedMotion.tsx

Accessibility

  • The loop copy carries inert, not just aria-hidden. The clone's contents are usually links or logos: aria-hidden alone leaves them focusable, so a keyboard user tabs into a duplicate set of controls that are mid-flight toward the container edge, and the focus ring travels off-screen with them. inert removes them from the a11y tree and the tab order in one attribute.
  • The root gets no role. It's a presentational band; the semantics belong to whatever you put inside it. Pass as="section" with an aria-label when the band is a landmark in its own right.
  • The track always pauses on focus-within, regardless of pauseOnHover. Tabbing into a link inside a moving band leaves that link travelling out from under the focus ring — and the user it strands is precisely the one who can't chase it with a pointer. Pausing for a pointer is a design choice; pausing for a keyboard isn't, so it isn't gated behind one. It costs nothing on a band with nothing focusable in it, where focus-within simply never matches.
  • pauseOnHover is off by default, and should stay off for decorative bands — it's a hover affordance, and offering one that leads nowhere is worse than offering none. Turn it on when the items are interactive or worth reading.

API

PropTypeDefaultDescription
childrenReactNode—The items to scroll. Rendered twice — once visibly, once as an inert clone.
direction'left' | 'right' | 'up' | 'down''left'Visual travel direction. Not mirrored under RTL — see below.
speed'slow' | 'normal' | 'fast''normal'Travel rate from ambientMotion.speed: 24 / 36 / 58 px per second.
durationnumber—Seconds per cycle, overriding speed. Re-introduces the content coupling speed removes — see above.
pauseOnHoverbooleanfalseFreeze the track while the pointer is over the band. (Focus always pauses — see Accessibility.)
repeatnumber1Copies of the children within each half of the track. Raise it when the child set is too short to fill the container.
gap0 | 1 | 2 | 3 | 4 | 6 | 8 | 12 | 168Spacing step between items.
fadebooleantrueMask the leading and trailing edges so items dissolve rather than clip.
fadeWidthstring'12%'Width of each fade edge.
reduceMotionboolean—Force the reduced-motion branch on/off, bypassing the media query. For tests and side-by-side docs.
as'div' | 'section' | 'aside''div'Element rendered for the container.
className / style / sx——Standard escape hatches.

Patterns

Trust band under a hero

A slow, faded logo band

Loading preview…
LogoBand.tsx

fade is what keeps this from reading as a clipped list — logos dissolve at both edges instead of being cut in half by the container.

Interactive items

Quote cards that pause under the cursor

Loading preview…
PauseOnHover.tsx

Speeds

The three named speeds

Loading preview…
Speeds.tsx

The named speeds are not the motion.duration tokens. Those are 150–300ms — interaction timings for something that starts, resolves and stops while you watch it. A marquee cycle is 20–60 seconds, and resolving speed="slow" against a 300ms token gives a strobe. Ambient motion that never resolves is a different quantity and gets its own scale.

SameRate

Loading preview…
SameRate.tsx

The two bands above hold different numbers of items and finish their cycles at different times — and travel at exactly the same speed. That's the whole point of the rate model.

Direction and RTL

left and right

Loading preview…
Directions.tsx

direction is the visual travel direction and is deliberately not flipped for RTL. A logo ticker is authored motion, not reading order — a designer asking for "logos drifting left" means left on screen in every locale. This matches how motionPresets.slideInFromLeft treats its offset. If you genuinely want direction-aware travel, read useDirection() and pick the value yourself.

Short child sets

tsx
<Marquee repeat={3}>
  {threeLogos}
</Marquee>
<Marquee repeat={3}>
  {threeLogos}
</Marquee>

The seamless loop needs the track to be at least twice the container's length. Three logos in a wide band won't manage that on their own, and the result is a visible empty stretch. repeat renders the children N times as siblings inside each group — not as nested groups — so the gap rhythm stays uniform and the two halves stay exactly equal, which is the invariant the seam depends on.

Note that repeat does not change the speed: it lengthens the track, and the duration is re-derived from the new measurement, so the band still travels at its token rate.

More examples

Basic

Loading preview…
Basic.tsx

Props

PropTypeDefaultDescription
children*ReactNode—The items to scroll. Rendered twice — once visibly, once as an `aria-hidden` clone — so the track can loop seamlessly. Keep the set small enough to fill the container at least once; see `repeat` when it isn't.
asenum'div'Element rendered for the outer container.
classNamestring——
directionenum'left'—
durationnumber—Seconds for one full cycle, overriding `speed` entirely. The escape hatch, and a narrow one: pinning a duration re-introduces exactly the content-length coupling `speed` exists to remove, so a band using it will not match the tempo of the bands around it once its content changes. Reach for it only when a single band is genuinely meant to run at its own pace.
fadebooleantrueFade the leading and trailing edges with a `mask-image` gradient, so items dissolve rather than clipping against the container edge. On by default: a hard clip at the container edge is the tell that a band was assembled rather than designed. Turning it off is opting out of the reviewed design, which is occasionally right — a marquee inside an already-masked or full-bleed container doesn't need a second one.
fadeWidthstring'min(12%, 48px)'Width of each fade edge as a CSS length. The default is percentage-based so the mask scales with the band, but capped in absolute terms: at 12% a 360px phone gets 43px of mask on *each* side, which stops softening the edge and starts cropping the first logo. `min()` keeps it a fade at every width.
gapenum8Space between items.
pauseOnHoverbooleanfalseFreeze the track while the pointer is over it. Off by default: a marquee that stops under the cursor is a hover *affordance*, which is only correct when the items are interactive. This governs the **pointer** only. The track always pauses on `focus-within`, whatever this is set to — a keyboard user must not have to chase a moving link.
reduceMotionboolean—Force the reduced-motion presentation on (`true`) or off (`false`), bypassing the media query. Mirrors `useReducedMotion(forceValue)`; exists for tests and for stories that need to show both presentations side by side.
refRef<HTMLElement>—Allows getting a ref to the component instance. Once the component unmounts, React will set `ref.current` to `null` (or call the ref with `null` if you passed a callback ref). @see {@link https://react.dev/learn/referencing-values-with-refs#refs-and-the-dom React Docs}
repeatnumber1How many times to repeat the children within a single half of the track. The seamless loop requires the track to be at least twice the container's length; when the child set is short (three logos in a wide band), one copy leaves a visible gap.
speedenum'normal'Travel rate, from the `ambientMotion.speed` token scale.
styleCSSProperties——
sxSx—Theme-aware inline style.