Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

DisplayReact

Marquee

Content that scrolls on its own, in a loop, with a button that stops it.

Rendered by React 19.3.1

Loading React…
import { Marquee } from '@hamur/react/marquee'

const partners = ['Orbit', 'Lumen', 'Cobalt', 'Nimbus', 'Quartz', 'Vertex']

export default function MarqueeExample() {
  return (
    <div>
      <h3 id="marquee-partners" className="marquee-heading">
        Partners
      </h3>
      <Marquee.Root
        className="marquee"
        aria-labelledby="marquee-partners"
        style={{ width: 340, maxWidth: '100%' }}
      >
        <Marquee.PlayControl
          className="button button-square marquee-control"
        >
          <svg className="marquee-icon-pause" width="16" height="16" viewBox="0 0 24 24">
            <rect width="4" height="16" x="14" y="4" rx="1" />
            <rect width="4" height="16" x="6" y="4" rx="1" />
          </svg>
          <svg className="marquee-icon-play" width="16" height="16" viewBox="0 0 24 24">
            <polygon points="6 3 20 12 6 21 6 3" />
          </svg>
        </Marquee.PlayControl>
        <Marquee.Viewport className="marquee-viewport">
          <Marquee.Content className="marquee-content" render={<ul />}>
            {partners.map((name) => (
              <li key={name}>
                <a className="marquee-logo" href={`#${name.toLowerCase()}`}>
                  {name}
                </a>
              </li>
            ))}
          </Marquee.Content>
        </Marquee.Viewport>
      </Marquee.Root>
    </div>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Marquee.Root>
  <Marquee.PlayControl />
  <Marquee.Viewport>
    <Marquee.Content />
  </Marquee.Viewport>
</Marquee.Root>

Examples

Vertical

Headlines that scroll upwards in a Viewport 120 px high.

MarqueeVertical.tsx
import { Marquee } from '@hamur/react/marquee'

const headlines = [
  'Rail line to the coast reopens',
  'Library extends weekend hours',
  'Night market returns to the harbor',
  'Home team wins the regional final',
  'New bike lanes open on Main Street',
]

export default function MarqueeVerticalExample() {
  return (
    <div>
      <h3 id="marquee-headlines" className="marquee-heading">
        Headlines
      </h3>
      <Marquee.Root
        className="marquee marquee-vertical"
        orientation="vertical"
        aria-labelledby="marquee-headlines"
        style={{ width: 340, maxWidth: '100%' }}
      >
        <Marquee.PlayControl className="button button-square marquee-control">
          <svg className="marquee-icon-pause" width="16" height="16" viewBox="0 0 24 24">
            <rect width="4" height="16" x="14" y="4" rx="1" />
            <rect width="4" height="16" x="6" y="4" rx="1" />
          </svg>
          <svg className="marquee-icon-play" width="16" height="16" viewBox="0 0 24 24">
            <polygon points="6 3 20 12 6 21 6 3" />
          </svg>
        </Marquee.PlayControl>
        <Marquee.Viewport className="marquee-viewport" style={{ height: 120 }}>
          <Marquee.Content className="marquee-content" render={<ul />}>
            {headlines.map((headline) => (
              <li key={headline}>
                <a className="marquee-headline" href="#headlines">
                  {headline}
                </a>
              </li>
            ))}
          </Marquee.Content>
        </Marquee.Viewport>
      </Marquee.Root>
    </div>
  )
}

Reverse

A departures strip with a Reverse button that turns its direction; the content keeps its place.

MarqueeReverse.tsx
import * as React from 'react'
import { Marquee } from '@hamur/react/marquee'

const departures = ['Lisbon 10:20', 'Oslo 10:45', 'Rome 11:05', 'Vienna 11:30', 'Dublin 11:50']

export default function MarqueeReverseExample() {
  const [reverse, setReverse] = React.useState(false)
  return (
    <div className="stack">
      <Marquee.Root
        className="marquee"
        aria-label="Departures"
        reverse={reverse}
        style={{ width: 340, maxWidth: '100%' }}
      >
        <Marquee.PlayControl className="button button-square marquee-control">
          <svg className="marquee-icon-pause" width="16" height="16" viewBox="0 0 24 24">
            <rect width="4" height="16" x="14" y="4" rx="1" />
            <rect width="4" height="16" x="6" y="4" rx="1" />
          </svg>
          <svg className="marquee-icon-play" width="16" height="16" viewBox="0 0 24 24">
            <polygon points="6 3 20 12 6 21 6 3" />
          </svg>
        </Marquee.PlayControl>
        <Marquee.Viewport className="marquee-viewport">
          <Marquee.Content className="marquee-content" render={<ul />}>
            {departures.map((departure) => (
              <li key={departure} className="marquee-departure">
                {departure}
              </li>
            ))}
          </Marquee.Content>
        </Marquee.Viewport>
      </Marquee.Root>
      <button
        type="button"
        className="button marquee-reverse"
        aria-pressed={reverse}
        onClick={() => setReverse((current) => !current)}
      >
        Reverse
      </button>
    </div>
  )
}

Overflow

A track title with overflowOnly: still while it fits, scrolling once a longer title is shown.

MarqueeOverflow.tsx
import * as React from 'react'
import { Marquee } from '@hamur/react/marquee'

const SHORT = 'Blue in Green'
const LONG = 'Piano Concerto No. 2 in C minor, Op. 18: II. Adagio sostenuto'

export default function MarqueeOverflowExample() {
  const [long, setLong] = React.useState(false)
  return (
    <div className="stack">
      <Marquee.Root
        className="marquee"
        aria-label="Now playing"
        overflowOnly
        style={{ width: 280, maxWidth: '100%' }}
      >
        <Marquee.PlayControl className="button button-square marquee-control">
          <svg className="marquee-icon-pause" width="16" height="16" viewBox="0 0 24 24">
            <rect width="4" height="16" x="14" y="4" rx="1" />
            <rect width="4" height="16" x="6" y="4" rx="1" />
          </svg>
          <svg className="marquee-icon-play" width="16" height="16" viewBox="0 0 24 24">
            <polygon points="6 3 20 12 6 21 6 3" />
          </svg>
        </Marquee.PlayControl>
        <Marquee.Viewport className="marquee-viewport">
          <Marquee.Content className="marquee-title">{long ? LONG : SHORT}</Marquee.Content>
        </Marquee.Viewport>
      </Marquee.Root>
      <button
        type="button"
        className="button marquee-length"
        aria-pressed={long}
        onClick={() => setLong((current) => !current)}
      >
        Long title
      </button>
    </div>
  )
}

Loop

An announcement with loopCount 3 that counts its passes and stops; the PlayControl runs it again.

MarqueeLoop.tsx
import * as React from 'react'
import { Marquee } from '@hamur/react/marquee'

export default function MarqueeLoopExample() {
  const [passes, setPasses] = React.useState(0)
  const [done, setDone] = React.useState(false)
  return (
    <div className="stack">
      <Marquee.Root
        className="marquee"
        aria-label="Announcement"
        loopCount={3}
        style={{ width: 340, maxWidth: '100%' }}
        onLoopComplete={setPasses}
        onComplete={() => setDone(true)}
        onPlayingChange={(playing) => {
          if (playing && done) {
            setDone(false)
            setPasses(0)
          }
        }}
      >
        <Marquee.PlayControl className="button button-square marquee-control">
          <svg className="marquee-icon-pause" width="16" height="16" viewBox="0 0 24 24">
            <rect width="4" height="16" x="14" y="4" rx="1" />
            <rect width="4" height="16" x="6" y="4" rx="1" />
          </svg>
          <svg className="marquee-icon-play" width="16" height="16" viewBox="0 0 24 24">
            <polygon points="6 3 20 12 6 21 6 3" />
          </svg>
        </Marquee.PlayControl>
        <Marquee.Viewport className="marquee-viewport">
          <Marquee.Content className="marquee-title">
            Free delivery on every order this weekend
          </Marquee.Content>
        </Marquee.Viewport>
      </Marquee.Root>
      <p className="muted marquee-log">{done ? 'Done after 3 passes' : `${passes} of 3 passes`}</p>
    </div>
  )
}

API reference

13 props across 4 parts, in React syntax. className, style, render are listed once, under Shared props.

ContentMarquee.Content

The moving content, drawn once and then once more per copy needed to fill the Viewport; the copies are hidden from assistive technology and left out of the tab order. Renders a <div>.

Content data attributes
Data attributeDescription
data-orientationIndicates the motion axis.
data-playingPresent while the motion is on.
data-pausedPresent while the motion is on but held by hover or a touch press.
data-reversePresent when the content moves toward the inline end (or the block end).
data-overflowingPresent while the content does not fit in the Viewport.
data-completedPresent once a finite loopCount has run.
data-reduced-motionPresent while the user asks for reduced motion.
data-indexThe position of the Content: 0 for the original, 1 to n for the copies.
data-copyPresent on each copy of the content.

PlayControlMarquee.PlayControl

Starts and stops the motion; named by what pressing it does. Place it before the Viewport. Renders a <button>.

PlayControl props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
PlayControl data attributes
Data attributeDescription
data-orientationIndicates the motion axis.
data-playingPresent while the motion is on.
data-pausedPresent while the motion is on but held by hover or a touch press.
data-reversePresent when the content moves toward the inline end (or the block end).
data-overflowingPresent while the content does not fit in the Viewport.
data-completedPresent once a finite loopCount has run.
data-reduced-motionPresent while the user asks for reduced motion.

RootMarquee.Root

Groups all parts of the marquee: content that scrolls on its own, in a loop, with a control that stops it. Renders a <div>.

Root props
PropTypeDefault
playingWhether the motion is on.boolean—
defaultPlayingWhether the motion is on when the component is initially rendered.booleantrue
onPlayingChangeEvent handler called when the motion starts or stops.((playing: boolean, eventDetails: MarqueeRootPlayingChangeEventDetails) => void)—
orientationThe motion axis.MarqueeOrientation'horizontal'
reverseWhether the content moves toward the inline end (or the block end) instead of the start.booleanfalse
speedThe speed of the motion, in CSS pixels per second.number50
loopCountThe passes before the motion ends; Infinity loops forever and 0 stays still.numberInfinity
onLoopCompleteEvent handler called after each pass, with the number of passes run.((count: number) => void)—
onCompleteEvent handler called once when a finite loopCount has run.(() => void)—
overflowOnlyWhether the content moves only while it does not fit in the Viewport.booleanfalse
labelsThe PlayControl's names, in English by default.Partial<MarqueeLabels>—
actionsRefA ref to imperative actions: play(), pause() and restart().React.RefObject<MarqueeRootActions | null>—
Root data attributes
Data attributeDescription
data-orientationIndicates the motion axis.
data-playingPresent while the motion is on.
data-pausedPresent while the motion is on but held by hover or a touch press.
data-reversePresent when the content moves toward the inline end (or the block end).
data-overflowingPresent while the content does not fit in the Viewport.
data-completedPresent once a finite loopCount has run.
data-reduced-motionPresent while the user asks for reduced motion.

ViewportMarquee.Viewport

The box that clips the moving content on its axis. Its CSS gap is the space between copies. Renders a <div>.

Viewport data attributes
Data attributeDescription
data-orientationIndicates the motion axis.
data-playingPresent while the motion is on.
data-pausedPresent while the motion is on but held by hover or a touch press.
data-reversePresent when the content moves toward the inline end (or the block end).
data-overflowingPresent while the content does not fit in the Viewport.
data-completedPresent once a finite loopCount has run.
data-reduced-motionPresent while the user asks for reduced motion.
Viewport CSS variables
CSS variableDescription
--marquee-durationThe duration of one pass, in seconds; 0s before the first measurement.
--marquee-gapThe Viewport's gap on the motion axis, in pixels.
--marquee-translateThe end of the keyframe for the axis and the direction.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
EnterSpaceStops or starts the motion; focus stays on the button and its name turnsPlayControl
TabKeyboard focus inside stops the motion; leaving starts nothingRoot
TabWalks the original's links once and leaves; the copies are not Tab stopsContent
TabBrings the focused item fully inside the ViewportViewport

Accessibility

What the parts set
PartSetsSo that
Rootrole="marquee" and aria-live="off", named by the app's aria-labelledby or aria-label; the app's role replaces itthe strip is said as content that changes on its own, and its changes are never read out
PlayControla button of type button named "Pause scrolling" while it moves and "Start scrolling" otherwise, unless it has text or an aria-label; no aria-pressedthe name says what a press does, and its change is the feedback for the press
PlayControlaria-disabled="true" while overflowOnly holds the motion off because the content fits; it stays focusablefocus is not lost when the button stops applying
Viewporta div with no role, clipped on its axis with overflow: clip, so focus never scrolls itthe moving box is not a scroll container a reader or the keyboard can get lost in
Contentthe original with no role and no ARIA, data-index="0"; each copy aria-hidden="true", data-copy, and tabindex="-1" on every tabbable element inside itthe content is read and tabbed through once, while every copy still takes a click

Focus order

  1. The PlayControl comes before the Viewport, so it is reached before the moving content.
  2. The original's focusable items follow, once each; the copies take no focus from Tab.
  3. Focus stays on the PlayControl through the press and the change of its name. Nothing takes focus on mount, after a pass or at the end.