Skip to content
@samline/ticker v2.1.0
Features Get started Recipes FAQ Examples Docs

@samline/ticker

Explore Ticker

Find your starting point. Make it your own.

  • 01 Features What Ticker brings to your project. ↗
  • 02 Get started Install, initialize and start moving. ↗
  • 03 Recipes Practical patterns for real integrations. ↗
  • 04 FAQ A few details before you build. ↗
  • 05 Examples Working recipes. Complete code to copy. ↗
  • 06 Docs The complete guide and API reference. ↗

Around the project

  • GitHub ↗ (opens in a new tab)
  • npm ↗ (opens in a new tab)

v2.1.0 · MIT

GitHub (opens in a new tab) Get started
Examples

Ticker examples.

Patterns to try, adjust and copy. Start with a live preview, then open the complete HTML / CDN or Package / ESM example below it.

Explore the examples↓ Get started↗

Pause any preview to read at your own pace. Reduced motion and readable static content are built in.

Choose an example

Announcements↓ Brand logos↓ Prices & stats↓ Opposite rows↓ Dynamic content↓ Playback controls↓ Manual navigation↓ Vertical news↓ Constant speed↓ RTL content↓ Responsive options↓ Drag and resume↓
Examples / 01

News and updates

Share releases, notices and useful links in a moving news strip. Visitors can follow the original links without tabbing through repeated copies.

Ticker resources
Start with a working ticker Make motion accessible Choose your configuration

Hover or focus a link to pause. The pause button works independently of pointer hover.

Read about links and clone accessibility ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>News and updates</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="News and updates">
      <section class="ticker-recipe">
        <h1>News and updates</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Hover or focus a link to pause. The pause button works independently of pointer hover.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', { duration: 28, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>News and updates</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="News and updates">
      <section class="ticker-recipe">
        <h1>News and updates</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Hover or focus a link to pause. The pause button works independently of pointer hover.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', { duration: 28, direction: 'left', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>News and updates</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="News and updates">
      <section class="ticker-recipe">
        <h1>News and updates</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Hover or focus a link to pause. The pause button works independently of pointer hover.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', { duration: 28, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>News and updates</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="News and updates">
      <section class="ticker-recipe">
        <h1>News and updates</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Hover or focus a link to pause. The pause button works independently of pointer hover.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', { duration: 28, direction: 'left', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 02

Partners and tools

Give partners, sponsors or tools a place on your page. This example includes the SVGs in its copied code, ready to use with attribution.

Example technology marks
Astro Vite GitHub Figma

Brand marks are illustrative and do not imply endorsement. Keep their attribution when reusing them.

Explore the styling and DOM contract ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Partners and tools</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Partners and tools">
      <section class="ticker-recipe">
        <h1>Partners and tools</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Example technology marks"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Astro</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Vite</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />GitHub</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Figma</span
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Brand marks are illustrative and do not imply endorsement. Keep their attribution when
          reusing them.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', { duration: 36, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Partners and tools</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Partners and tools">
      <section class="ticker-recipe">
        <h1>Partners and tools</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Example technology marks"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Astro</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Vite</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />GitHub</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Figma</span
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Brand marks are illustrative and do not imply endorsement. Keep their attribution when
          reusing them.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', { duration: 36, direction: 'left', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Partners and tools</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Partners and tools">
      <section class="ticker-recipe">
        <h1>Partners and tools</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Example technology marks"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Astro</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Vite</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />GitHub</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Figma</span
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Brand marks are illustrative and do not imply endorsement. Keep their attribution when
          reusing them.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', { duration: 36, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Partners and tools</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Partners and tools">
      <section class="ticker-recipe">
        <h1>Partners and tools</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Example technology marks"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Astro</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Vite</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />GitHub</span
            >
            <span class="item"
              ><img
                src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                alt=""
              />Figma</span
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Brand marks are illustrative and do not imply endorsement. Keep their attribution when
          reusing them.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', { duration: 36, direction: 'left', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 03

Prices and metrics

Display prices, scores or other changing numbers in a compact row. Plus and minus signs make the trend clear alongside color.

Illustrative market data
ACME $128.40 +2.4% ORBIT $84.12 −0.8% LAYER $206.55 +1.3% NORTH $62.70 +0.6%

Illustrative data only. These values do not update from a market feed.

Adjust timing and direction ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Prices and metrics</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Prices and metrics">
      <section class="ticker-recipe">
        <h1>Prices and metrics</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Illustrative market data"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"><strong>ACME</strong> $128.40 <span class="up">+2.4%</span></span>
            <span class="item"><strong>ORBIT</strong> $84.12 <span class="down">−0.8%</span></span>
            <span class="item"><strong>LAYER</strong> $206.55 <span class="up">+1.3%</span></span>
            <span class="item"><strong>NORTH</strong> $62.70 <span class="up">+0.6%</span></span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>Illustrative data only. These values do not update from a market feed.</p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', { duration: 32, direction: 'right', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Prices and metrics</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Prices and metrics">
      <section class="ticker-recipe">
        <h1>Prices and metrics</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Illustrative market data"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"><strong>ACME</strong> $128.40 <span class="up">+2.4%</span></span>
            <span class="item"><strong>ORBIT</strong> $84.12 <span class="down">−0.8%</span></span>
            <span class="item"><strong>LAYER</strong> $206.55 <span class="up">+1.3%</span></span>
            <span class="item"><strong>NORTH</strong> $62.70 <span class="up">+0.6%</span></span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>Illustrative data only. These values do not update from a market feed.</p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', { duration: 32, direction: 'right', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Prices and metrics</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Prices and metrics">
      <section class="ticker-recipe">
        <h1>Prices and metrics</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Illustrative market data"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"><strong>ACME</strong> $128.40 <span class="up">+2.4%</span></span>
            <span class="item"><strong>ORBIT</strong> $84.12 <span class="down">−0.8%</span></span>
            <span class="item"><strong>LAYER</strong> $206.55 <span class="up">+1.3%</span></span>
            <span class="item"><strong>NORTH</strong> $62.70 <span class="up">+0.6%</span></span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>Illustrative data only. These values do not update from a market feed.</p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', { duration: 32, direction: 'right', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Prices and metrics</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Prices and metrics">
      <section class="ticker-recipe">
        <h1>Prices and metrics</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Illustrative market data"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item"><strong>ACME</strong> $128.40 <span class="up">+2.4%</span></span>
            <span class="item"><strong>ORBIT</strong> $84.12 <span class="down">−0.8%</span></span>
            <span class="item"><strong>LAYER</strong> $206.55 <span class="up">+1.3%</span></span>
            <span class="item"><strong>NORTH</strong> $62.70 <span class="up">+0.6%</span></span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>Illustrative data only. These values do not update from a market feed.</p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', { duration: 32, direction: 'right', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 04

Two rows, two directions

Pair two strips moving in opposite directions, each at its own pace. In the preview, you can pause either row without stopping the other.

Creative disciplines
Design Development Motion Accessibility
Ways of working
Build with care Keep it readable Ship something useful

Each row is a separate instance. They share content styling, not runtime state. The standalone example includes a combined pause control.

Understand controller ownership and cleanup ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Two rows, two directions</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Two rows, two directions">
      <section class="ticker-recipe">
        <h1>Two rows, two directions</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Creative disciplines"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Design</span>
            <span class="item">Development</span>
            <span class="item">Motion</span>
            <span class="item">Accessibility</span>
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ways of working"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <span class="item">Build with care</span>
            <span class="item">Keep it readable</span>
            <span class="item">Ship something useful</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Each row is a separate instance. They share content styling, not runtime state. The
          standalone example includes a combined pause control.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const tickers = [
  ticker('#ticker-1', { duration: 26, direction: 'left', pauseOnHover: true }),
  ticker('#ticker-2', { duration: 38, direction: 'right', pauseOnHover: true }),
].filter(Boolean)
const news = tickers[0]

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = tickers.some(item => item.state.requested)
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  tickers.forEach(news => {
    news.on('playback', renderPlayback)
  })
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = tickers.some(item => item.state.requested)
      tickers.forEach(news => {
        playing ? news.pause() : news.play()
      })
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      tickers.forEach(news => {
        news.refresh()
      })
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      tickers.forEach(news => {
        news.destroy()
      })
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Two rows, two directions</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Two rows, two directions">
      <section class="ticker-recipe">
        <h1>Two rows, two directions</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Creative disciplines"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Design</span>
            <span class="item">Development</span>
            <span class="item">Motion</span>
            <span class="item">Accessibility</span>
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ways of working"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <span class="item">Build with care</span>
            <span class="item">Keep it readable</span>
            <span class="item">Ship something useful</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Each row is a separate instance. They share content styling, not runtime state. The
          standalone example includes a combined pause control.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const tickers = [
        ticker('#ticker-1', { duration: 26, direction: 'left', pauseOnHover: true }),
        ticker('#ticker-2', { duration: 38, direction: 'right', pauseOnHover: true }),
      ].filter(Boolean)
      const news = tickers[0]

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = tickers.some(item => item.state.requested)
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        tickers.forEach(news => {
          news.on('playback', renderPlayback)
        })
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = tickers.some(item => item.state.requested)
            tickers.forEach(news => {
              playing ? news.pause() : news.play()
            })
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            tickers.forEach(news => {
              news.refresh()
            })
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            tickers.forEach(news => {
              news.destroy()
            })
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Two rows, two directions</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Two rows, two directions">
      <section class="ticker-recipe">
        <h1>Two rows, two directions</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Creative disciplines"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Design</span>
            <span class="item">Development</span>
            <span class="item">Motion</span>
            <span class="item">Accessibility</span>
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ways of working"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <span class="item">Build with care</span>
            <span class="item">Keep it readable</span>
            <span class="item">Ship something useful</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Each row is a separate instance. They share content styling, not runtime state. The
          standalone example includes a combined pause control.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const tickers = [
  ticker('#ticker-1', { duration: 26, direction: 'left', pauseOnHover: true }),
  ticker('#ticker-2', { duration: 38, direction: 'right', pauseOnHover: true }),
].filter(Boolean)
const news = tickers[0]

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = tickers.some(item => item.state.requested)
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  tickers.forEach(news => {
    news.on('playback', renderPlayback)
  })
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = tickers.some(item => item.state.requested)
      tickers.forEach(news => {
        playing ? news.pause() : news.play()
      })
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      tickers.forEach(news => {
        news.refresh()
      })
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      tickers.forEach(news => {
        news.destroy()
      })
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Two rows, two directions</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Two rows, two directions">
      <section class="ticker-recipe">
        <h1>Two rows, two directions</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Creative disciplines"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Design</span>
            <span class="item">Development</span>
            <span class="item">Motion</span>
            <span class="item">Accessibility</span>
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Ways of working"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <span class="item">Build with care</span>
            <span class="item">Keep it readable</span>
            <span class="item">Ship something useful</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Each row is a separate instance. They share content styling, not runtime state. The
          standalone example includes a combined pause control.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const tickers = [
        ticker('#ticker-1', { duration: 26, direction: 'left', pauseOnHover: true }),
        ticker('#ticker-2', { duration: 38, direction: 'right', pauseOnHover: true }),
      ].filter(Boolean)
      const news = tickers[0]

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = tickers.some(item => item.state.requested)
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        tickers.forEach(news => {
          news.on('playback', renderPlayback)
        })
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = tickers.some(item => item.state.requested)
            tickers.forEach(news => {
              playing ? news.pause() : news.play()
            })
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            tickers.forEach(news => {
              news.refresh()
            })
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            tickers.forEach(news => {
              news.destroy()
            })
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 05

A feed that grows

Add or remove announcements while the feed keeps a constant 40 pixels per second. Try removing the first, last or selected item; the original content and its visual copies stay in sync.

Live example updates
Project created Design approved Ready to ship

The application owns the items and calls refresh() after editing the source. Pixel speed keeps the pace steady as the feed grows or shrinks.

Learn when to update() or refresh() ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>A feed that grows</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="A feed that grows">
      <section class="ticker-recipe">
        <h1>A feed that grows</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Live example updates"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item" data-example-item="item-0">Project created</span>
            <span class="item" data-example-item="item-1">Design approved</span>
            <span class="item" data-example-item="item-2">Ready to ship</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-add>Add an update</button>
          <button type="button" data-remove-first>Remove first</button>
          <button type="button" data-remove-last>Remove last</button>
          <label
            >Choose an item
            <select data-item-select></select
          ></label>
          <button type="button" data-remove-selected>Remove selected</button>
        </div>
        <p>
          The application owns the items and calls refresh() after editing the source. Pixel speed
          keeps the pace steady as the feed grows or shrinks.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', { duration: 28, speed: 40, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  const select = example.querySelector('[data-item-select]')
  const status = example.querySelector('[role=status]')
  const items = () => [...news.source.querySelectorAll('[data-example-item]')]
  const removeButtons = example.querySelectorAll(
    '[data-remove-first], [data-remove-last], [data-remove-selected]'
  )
  const syncItems = () => {
    const selected = select.value
    const current = items()
    select.replaceChildren(
      ...current.map(item => new Option(item.textContent, item.dataset.exampleItem))
    )
    if (current.some(item => item.dataset.exampleItem === selected)) select.value = selected
    if (!current.length) select.add(new Option('No items left', ''))
    select.disabled = !current.length
    removeButtons.forEach(button => {
      button.disabled = !current.length
    })
  }
  const removeItem = item => {
    if (!item) return
    const label = item.textContent
    item.remove()
    news.refresh()
    syncItems()
    status.textContent = 'Removed {item}. Visual copies refreshed.'.replace('{item}', label)
  }
  example
    .querySelector('[data-remove-first]')
    .addEventListener('click', () => removeItem(items()[0]), { signal })
  example
    .querySelector('[data-remove-last]')
    .addEventListener('click', () => removeItem(items().at(-1)), { signal })
  example.querySelector('[data-remove-selected]').addEventListener(
    'click',
    () => {
      removeItem(items().find(item => item.dataset.exampleItem === select.value))
    },
    { signal }
  )
  syncItems()
  let count = 0
  example.querySelector('[data-add]').addEventListener(
    'click',
    () => {
      const item = document.createElement('span')
      item.className = 'item'
      item.dataset.exampleItem = 'added-' + ++count
      item.textContent = 'New update {count}'.replace('{count}', String(count))
      news.source.append(item)
      news.refresh()
      syncItems()
      status.textContent = 'Added update {count}. Visual copies refreshed.'.replace(
        '{count}',
        String(count)
      )
    },
    { signal }
  )
  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>A feed that grows</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="A feed that grows">
      <section class="ticker-recipe">
        <h1>A feed that grows</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Live example updates"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item" data-example-item="item-0">Project created</span>
            <span class="item" data-example-item="item-1">Design approved</span>
            <span class="item" data-example-item="item-2">Ready to ship</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-add>Add an update</button>
          <button type="button" data-remove-first>Remove first</button>
          <button type="button" data-remove-last>Remove last</button>
          <label
            >Choose an item
            <select data-item-select></select
          ></label>
          <button type="button" data-remove-selected>Remove selected</button>
        </div>
        <p>
          The application owns the items and calls refresh() after editing the source. Pixel speed
          keeps the pace steady as the feed grows or shrinks.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', {
        duration: 28,
        speed: 40,
        direction: 'left',
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        const select = example.querySelector('[data-item-select]')
        const status = example.querySelector('[role=status]')
        const items = () => [...news.source.querySelectorAll('[data-example-item]')]
        const removeButtons = example.querySelectorAll(
          '[data-remove-first], [data-remove-last], [data-remove-selected]'
        )
        const syncItems = () => {
          const selected = select.value
          const current = items()
          select.replaceChildren(
            ...current.map(item => new Option(item.textContent, item.dataset.exampleItem))
          )
          if (current.some(item => item.dataset.exampleItem === selected)) select.value = selected
          if (!current.length) select.add(new Option('No items left', ''))
          select.disabled = !current.length
          removeButtons.forEach(button => {
            button.disabled = !current.length
          })
        }
        const removeItem = item => {
          if (!item) return
          const label = item.textContent
          item.remove()
          news.refresh()
          syncItems()
          status.textContent = 'Removed {item}. Visual copies refreshed.'.replace('{item}', label)
        }
        example
          .querySelector('[data-remove-first]')
          .addEventListener('click', () => removeItem(items()[0]), { signal })
        example
          .querySelector('[data-remove-last]')
          .addEventListener('click', () => removeItem(items().at(-1)), { signal })
        example.querySelector('[data-remove-selected]').addEventListener(
          'click',
          () => {
            removeItem(items().find(item => item.dataset.exampleItem === select.value))
          },
          { signal }
        )
        syncItems()
        let count = 0
        example.querySelector('[data-add]').addEventListener(
          'click',
          () => {
            const item = document.createElement('span')
            item.className = 'item'
            item.dataset.exampleItem = 'added-' + ++count
            item.textContent = 'New update {count}'.replace('{count}', String(count))
            news.source.append(item)
            news.refresh()
            syncItems()
            status.textContent = 'Added update {count}. Visual copies refreshed.'.replace(
              '{count}',
              String(count)
            )
          },
          { signal }
        )
        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>A feed that grows</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="A feed that grows">
      <section class="ticker-recipe">
        <h1>A feed that grows</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Live example updates"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item" data-example-item="item-0">Project created</span>
            <span class="item" data-example-item="item-1">Design approved</span>
            <span class="item" data-example-item="item-2">Ready to ship</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-add>Add an update</button>
          <button type="button" data-remove-first>Remove first</button>
          <button type="button" data-remove-last>Remove last</button>
          <label
            >Choose an item
            <select data-item-select></select
          ></label>
          <button type="button" data-remove-selected>Remove selected</button>
        </div>
        <p>
          The application owns the items and calls refresh() after editing the source. Pixel speed
          keeps the pace steady as the feed grows or shrinks.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', { duration: 28, speed: 40, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  const select = example.querySelector('[data-item-select]')
  const status = example.querySelector('[role=status]')
  const items = () => [...news.source.querySelectorAll('[data-example-item]')]
  const removeButtons = example.querySelectorAll(
    '[data-remove-first], [data-remove-last], [data-remove-selected]'
  )
  const syncItems = () => {
    const selected = select.value
    const current = items()
    select.replaceChildren(
      ...current.map(item => new Option(item.textContent, item.dataset.exampleItem))
    )
    if (current.some(item => item.dataset.exampleItem === selected)) select.value = selected
    if (!current.length) select.add(new Option('No items left', ''))
    select.disabled = !current.length
    removeButtons.forEach(button => {
      button.disabled = !current.length
    })
  }
  const removeItem = item => {
    if (!item) return
    const label = item.textContent
    item.remove()
    news.refresh()
    syncItems()
    status.textContent = 'Removed {item}. Visual copies refreshed.'.replace('{item}', label)
  }
  example
    .querySelector('[data-remove-first]')
    .addEventListener('click', () => removeItem(items()[0]), { signal })
  example
    .querySelector('[data-remove-last]')
    .addEventListener('click', () => removeItem(items().at(-1)), { signal })
  example.querySelector('[data-remove-selected]').addEventListener(
    'click',
    () => {
      removeItem(items().find(item => item.dataset.exampleItem === select.value))
    },
    { signal }
  )
  syncItems()
  let count = 0
  example.querySelector('[data-add]').addEventListener(
    'click',
    () => {
      const item = document.createElement('span')
      item.className = 'item'
      item.dataset.exampleItem = 'added-' + ++count
      item.textContent = 'New update {count}'.replace('{count}', String(count))
      news.source.append(item)
      news.refresh()
      syncItems()
      status.textContent = 'Added update {count}. Visual copies refreshed.'.replace(
        '{count}',
        String(count)
      )
    },
    { signal }
  )
  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>A feed that grows</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="A feed that grows">
      <section class="ticker-recipe">
        <h1>A feed that grows</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Live example updates"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item" data-example-item="item-0">Project created</span>
            <span class="item" data-example-item="item-1">Design approved</span>
            <span class="item" data-example-item="item-2">Ready to ship</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-add>Add an update</button>
          <button type="button" data-remove-first>Remove first</button>
          <button type="button" data-remove-last>Remove last</button>
          <label
            >Choose an item
            <select data-item-select></select
          ></label>
          <button type="button" data-remove-selected>Remove selected</button>
        </div>
        <p>
          The application owns the items and calls refresh() after editing the source. Pixel speed
          keeps the pace steady as the feed grows or shrinks.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', {
        duration: 28,
        speed: 40,
        direction: 'left',
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        const select = example.querySelector('[data-item-select]')
        const status = example.querySelector('[role=status]')
        const items = () => [...news.source.querySelectorAll('[data-example-item]')]
        const removeButtons = example.querySelectorAll(
          '[data-remove-first], [data-remove-last], [data-remove-selected]'
        )
        const syncItems = () => {
          const selected = select.value
          const current = items()
          select.replaceChildren(
            ...current.map(item => new Option(item.textContent, item.dataset.exampleItem))
          )
          if (current.some(item => item.dataset.exampleItem === selected)) select.value = selected
          if (!current.length) select.add(new Option('No items left', ''))
          select.disabled = !current.length
          removeButtons.forEach(button => {
            button.disabled = !current.length
          })
        }
        const removeItem = item => {
          if (!item) return
          const label = item.textContent
          item.remove()
          news.refresh()
          syncItems()
          status.textContent = 'Removed {item}. Visual copies refreshed.'.replace('{item}', label)
        }
        example
          .querySelector('[data-remove-first]')
          .addEventListener('click', () => removeItem(items()[0]), { signal })
        example
          .querySelector('[data-remove-last]')
          .addEventListener('click', () => removeItem(items().at(-1)), { signal })
        example.querySelector('[data-remove-selected]').addEventListener(
          'click',
          () => {
            removeItem(items().find(item => item.dataset.exampleItem === select.value))
          },
          { signal }
        )
        syncItems()
        let count = 0
        example.querySelector('[data-add]').addEventListener(
          'click',
          () => {
            const item = document.createElement('span')
            item.className = 'item'
            item.dataset.exampleItem = 'added-' + ++count
            item.textContent = 'New update {count}'.replace('{count}', String(count))
            news.source.append(item)
            news.refresh()
            syncItems()
            status.textContent = 'Added update {count}. Visual copies refreshed.'.replace(
              '{count}',
              String(count)
            )
          },
          { signal }
        )
        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 06

Speed and direction

Find a pace that suits your content, then try the opposite direction. A shorter loop moves faster; your content stays in place while the settings change.

Adjustable ticker
Your content Your direction Your pace One small library

A shorter duration moves faster. Focus inside the preview pauses motion so controls and content stay usable.

See the update() contract ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Speed and direction</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Speed and direction">
      <section class="ticker-recipe">
        <h1>Speed and direction</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Adjustable ticker"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Your content</span>
            <span class="item">Your direction</span>
            <span class="item">Your pace</span>
            <span class="item">One small library</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <label
            >Loop duration (seconds)
            <input
              type="range"
              min="14"
              max="60"
              value="24"
              aria-label="Loop duration (seconds)"
            /><output>24s</output></label
          >
          <label
            >Direction
            <select data-example-direction-select aria-label="Direction">
              <option value="left">Left</option>
              <option value="right">Right</option>
            </select></label
          >
        </div>
        <p>
          A shorter duration moves faster. Focus inside the preview pauses motion so controls and
          content stay usable.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', { duration: 24, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  const duration = example.querySelector('input')
  const output = example.querySelector('output')
  duration.addEventListener(
    'input',
    () => {
      news.update({ duration: Number(duration.value) })
      output.value = duration.value + 's'
    },
    { signal }
  )
  const direction = example.querySelector('[data-example-direction-select]')
  direction.addEventListener(
    'change',
    () => {
      news.update({ direction: direction.value })
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Speed and direction</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Speed and direction">
      <section class="ticker-recipe">
        <h1>Speed and direction</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Adjustable ticker"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Your content</span>
            <span class="item">Your direction</span>
            <span class="item">Your pace</span>
            <span class="item">One small library</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <label
            >Loop duration (seconds)
            <input
              type="range"
              min="14"
              max="60"
              value="24"
              aria-label="Loop duration (seconds)"
            /><output>24s</output></label
          >
          <label
            >Direction
            <select data-example-direction-select aria-label="Direction">
              <option value="left">Left</option>
              <option value="right">Right</option>
            </select></label
          >
        </div>
        <p>
          A shorter duration moves faster. Focus inside the preview pauses motion so controls and
          content stay usable.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', { duration: 24, direction: 'left', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        const duration = example.querySelector('input')
        const output = example.querySelector('output')
        duration.addEventListener(
          'input',
          () => {
            news.update({ duration: Number(duration.value) })
            output.value = duration.value + 's'
          },
          { signal }
        )
        const direction = example.querySelector('[data-example-direction-select]')
        direction.addEventListener(
          'change',
          () => {
            news.update({ direction: direction.value })
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Speed and direction</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Speed and direction">
      <section class="ticker-recipe">
        <h1>Speed and direction</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Adjustable ticker"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Your content</span>
            <span class="item">Your direction</span>
            <span class="item">Your pace</span>
            <span class="item">One small library</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <label
            >Loop duration (seconds)
            <input
              type="range"
              min="14"
              max="60"
              value="24"
              aria-label="Loop duration (seconds)"
            /><output>24s</output></label
          >
          <label
            >Direction
            <select data-example-direction-select aria-label="Direction">
              <option value="left">Left</option>
              <option value="right">Right</option>
            </select></label
          >
        </div>
        <p>
          A shorter duration moves faster. Focus inside the preview pauses motion so controls and
          content stay usable.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', { duration: 24, direction: 'left', pauseOnHover: true })

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  const duration = example.querySelector('input')
  const output = example.querySelector('output')
  duration.addEventListener(
    'input',
    () => {
      news.update({ duration: Number(duration.value) })
      output.value = duration.value + 's'
    },
    { signal }
  )
  const direction = example.querySelector('[data-example-direction-select]')
  direction.addEventListener(
    'change',
    () => {
      news.update({ direction: direction.value })
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Speed and direction</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Speed and direction">
      <section class="ticker-recipe">
        <h1>Speed and direction</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Adjustable ticker"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Your content</span>
            <span class="item">Your direction</span>
            <span class="item">Your pace</span>
            <span class="item">One small library</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <label
            >Loop duration (seconds)
            <input
              type="range"
              min="14"
              max="60"
              value="24"
              aria-label="Loop duration (seconds)"
            /><output>24s</output></label
          >
          <label
            >Direction
            <select data-example-direction-select aria-label="Direction">
              <option value="left">Left</option>
              <option value="right">Right</option>
            </select></label
          >
        </div>
        <p>
          A shorter duration moves faster. Focus inside the preview pauses motion so controls and
          content stay usable.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', { duration: 24, direction: 'left', pauseOnHover: true })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        const duration = example.querySelector('input')
        const output = example.querySelector('output')
        duration.addEventListener(
          'input',
          () => {
            news.update({ duration: Number(duration.value) })
            output.value = duration.value + 's'
          },
          { signal }
        )
        const direction = example.querySelector('[data-example-direction-select]')
        direction.addEventListener(
          'change',
          () => {
            news.update({ direction: direction.value })
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 07

Read at your own pace

Start paused. Use Previous, Next, the position rail or a pointer gesture to explore a repeating sequence. Press Play, then drag: playback resumes after release when hover and focus pauses clear. Buttons and the rail leave playback paused.

Manual news navigation
Start with a working ticker Make motion accessible Choose your configuration

Buttons advance one visible extent. The rail represents one repeated sequence and wraps at its end. Drag horizontally; vertical page scrolling stays available.

Read the manual navigation contract ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Read at your own pace</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Read at your own pace">
      <section class="ticker-recipe">
        <h1>Read at your own pace</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Manual news navigation"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Buttons advance one visible extent. The rail represents one repeated sequence and wraps at
          its end. Drag horizontally; vertical page scrolling stays available.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', {
  duration: 20,
  direction: 'left',
  autoplay: false,
  navigation: true,
  resumeAfterDrag: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Read at your own pace</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Read at your own pace">
      <section class="ticker-recipe">
        <h1>Read at your own pace</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Manual news navigation"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Buttons advance one visible extent. The rail represents one repeated sequence and wraps at
          its end. Drag horizontally; vertical page scrolling stays available.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', {
        duration: 20,
        direction: 'left',
        autoplay: false,
        navigation: true,
        resumeAfterDrag: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Read at your own pace</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Read at your own pace">
      <section class="ticker-recipe">
        <h1>Read at your own pace</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Manual news navigation"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Buttons advance one visible extent. The rail represents one repeated sequence and wraps at
          its end. Drag horizontally; vertical page scrolling stays available.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', {
  duration: 20,
  direction: 'left',
  autoplay: false,
  navigation: true,
  resumeAfterDrag: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Read at your own pace</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Read at your own pace">
      <section class="ticker-recipe">
        <h1>Read at your own pace</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Manual news navigation"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Buttons advance one visible extent. The rail represents one repeated sequence and wraps at
          its end. Drag horizontally; vertical page scrolling stays available.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', {
        duration: 20,
        direction: 'left',
        autoplay: false,
        navigation: true,
        resumeAfterDrag: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 08

Vertical news

A scrolling resource feed with illustrated cards, useful links and descriptions. Each card keeps its own height while the column moves at 28 pixels per second.

Vertical ticker resources
Astro: start with your own markup ↗

Initialize a ticker after the page mounts and keep its original links available.

Vite: choose a steady reading pace ↗

Use pixel speed and viewport rules to keep a feed readable as its layout changes.

GitHub: keep live content in sync ↗

Add items to the original source, refresh its copies and release the controller on cleanup.

Figma: make motion accessible ↗

Give keyboard users time to read with focus pause and respect reduced motion preferences.

Illustrative technology marks, not product announcements or endorsements. Choose Up or Down to change movement. Hover or focus a link to read; use Previous, Next, the rail or drag to browse.

Configure vertical movement ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Vertical news</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Vertical news">
      <section class="ticker-recipe">
        <h1>Vertical news</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="true"
          tabindex="0"
          role="region"
          aria-label="Vertical ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/getting-started/"
                  >Astro: start with your own markup</a
                ><span
                  >Initialize a ticker after the page mounts and keep its original links
                  available.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/reference/configuration/"
                  >Vite: choose a steady reading pace</a
                ><span
                  >Use pixel speed and viewport rules to keep a feed readable as its layout
                  changes.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/lifecycle/"
                  >GitHub: keep live content in sync</a
                ><span
                  >Add items to the original source, refresh its copies and release the controller
                  on cleanup.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/accessibility/"
                  >Figma: make motion accessible</a
                ><span
                  >Give keyboard users time to read with focus pause and respect reduced motion
                  preferences.</span
                ></span
              >
            </div>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
          <div role="group" aria-label="Direction">
            <button type="button" data-example-direction="up" aria-pressed="true">Up</button
            ><button type="button" data-example-direction="down" aria-pressed="false">Down</button>
          </div>
        </div>
        <p>
          Illustrative technology marks, not product announcements or endorsements. Choose Up or
          Down to change movement. Hover or focus a link to read; use Previous, Next, the rail or
          drag to browse.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', {
  duration: 20,
  speed: 28,
  direction: 'up',
  navigation: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  const directions = example.querySelectorAll('button[data-example-direction]')
  directions.forEach(button => {
    button.addEventListener(
      'click',
      () => {
        news.update({ direction: button.dataset.exampleDirection })
        directions.forEach(item => item.setAttribute('aria-pressed', String(item === button)))
      },
      { signal }
    )
  })

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Vertical news</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Vertical news">
      <section class="ticker-recipe">
        <h1>Vertical news</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="true"
          tabindex="0"
          role="region"
          aria-label="Vertical ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/getting-started/"
                  >Astro: start with your own markup</a
                ><span
                  >Initialize a ticker after the page mounts and keep its original links
                  available.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/reference/configuration/"
                  >Vite: choose a steady reading pace</a
                ><span
                  >Use pixel speed and viewport rules to keep a feed readable as its layout
                  changes.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/lifecycle/"
                  >GitHub: keep live content in sync</a
                ><span
                  >Add items to the original source, refresh its copies and release the controller
                  on cleanup.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/accessibility/"
                  >Figma: make motion accessible</a
                ><span
                  >Give keyboard users time to read with focus pause and respect reduced motion
                  preferences.</span
                ></span
              >
            </div>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
          <div role="group" aria-label="Direction">
            <button type="button" data-example-direction="up" aria-pressed="true">Up</button
            ><button type="button" data-example-direction="down" aria-pressed="false">Down</button>
          </div>
        </div>
        <p>
          Illustrative technology marks, not product announcements or endorsements. Choose Up or
          Down to change movement. Hover or focus a link to read; use Previous, Next, the rail or
          drag to browse.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', {
        duration: 20,
        speed: 28,
        direction: 'up',
        navigation: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        const directions = example.querySelectorAll('button[data-example-direction]')
        directions.forEach(button => {
          button.addEventListener(
            'click',
            () => {
              news.update({ direction: button.dataset.exampleDirection })
              directions.forEach(item => item.setAttribute('aria-pressed', String(item === button)))
            },
            { signal }
          )
        })

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Vertical news</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Vertical news">
      <section class="ticker-recipe">
        <h1>Vertical news</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="true"
          tabindex="0"
          role="region"
          aria-label="Vertical ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/getting-started/"
                  >Astro: start with your own markup</a
                ><span
                  >Initialize a ticker after the page mounts and keep its original links
                  available.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/reference/configuration/"
                  >Vite: choose a steady reading pace</a
                ><span
                  >Use pixel speed and viewport rules to keep a feed readable as its layout
                  changes.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/lifecycle/"
                  >GitHub: keep live content in sync</a
                ><span
                  >Add items to the original source, refresh its copies and release the controller
                  on cleanup.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/accessibility/"
                  >Figma: make motion accessible</a
                ><span
                  >Give keyboard users time to read with focus pause and respect reduced motion
                  preferences.</span
                ></span
              >
            </div>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
          <div role="group" aria-label="Direction">
            <button type="button" data-example-direction="up" aria-pressed="true">Up</button
            ><button type="button" data-example-direction="down" aria-pressed="false">Down</button>
          </div>
        </div>
        <p>
          Illustrative technology marks, not product announcements or endorsements. Choose Up or
          Down to change movement. Hover or focus a link to read; use Previous, Next, the rail or
          drag to browse.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', {
  duration: 20,
  speed: 28,
  direction: 'up',
  navigation: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  const directions = example.querySelectorAll('button[data-example-direction]')
  directions.forEach(button => {
    button.addEventListener(
      'click',
      () => {
        news.update({ direction: button.dataset.exampleDirection })
        directions.forEach(item => item.setAttribute('aria-pressed', String(item === button)))
      },
      { signal }
    )
  })

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Vertical news</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Vertical news">
      <section class="ticker-recipe">
        <h1>Vertical news</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="true"
          tabindex="0"
          role="region"
          aria-label="Vertical ticker resources"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EAstro%3C%2Ftitle%3E%3Cpath%20d%3D%22M8.358%2020.162c-1.186-1.07-1.532-3.316-1.038-4.944.856%201.026%202.043%201.352%203.272%201.535%201.897.283%203.76.177%205.522-.678.202-.098.388-.229.608-.36.166.473.209.95.151%201.437-.14%201.185-.738%202.1-1.688%202.794-.38.277-.782.525-1.175.787-1.205.804-1.531%201.747-1.078%203.119l.044.148a3.158%203.158%200%200%201-1.407-1.188%203.31%203.31%200%200%201-.544-1.815c-.004-.32-.004-.642-.048-.958-.106-.769-.472-1.113-1.161-1.133-.707-.02-1.267.411-1.415%201.09-.012.053-.028.104-.045.165h.002zm-5.961-4.445s3.24-1.575%206.49-1.575l2.451-7.565c.092-.366.36-.614.662-.614.302%200%20.57.248.662.614l2.45%207.565c3.85%200%206.491%201.575%206.491%201.575L16.088.727C15.93.285%2015.663%200%2015.303%200H8.697c-.36%200-.615.285-.784.727l-5.516%2014.99z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/getting-started/"
                  >Astro: start with your own markup</a
                ><span
                  >Initialize a ticker after the page mounts and keep its original links
                  available.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EVite%3C%2Ftitle%3E%3Cpath%20d%3D%22m8.286%2010.578.512-8.657a.306.306%200%200%201%20.247-.282L17.377.006a.306.306%200%200%201%20.353.385l-1.558%205.403a.306.306%200%200%200%20.352.385l2.388-.46a.306.306%200%200%201%20.332.438l-6.79%2013.55-.123.19a.294.294%200%200%201-.252.14c-.177%200-.35-.152-.305-.369l1.095-5.301a.306.306%200%200%200-.388-.355l-1.433.435a.306.306%200%200%201-.389-.354l.69-3.375a.306.306%200%200%200-.37-.36l-2.32.536a.306.306%200%200%201-.374-.316zm14.976-7.926L17.284%203.74l-.544%201.887%202.077-.4a.8.8%200%200%201%20.84.369.8.8%200%200%201%20.034.783L12.9%2019.93l-.013.025-.015.023-.122.19a.801.801%200%200%201-.672.37.826.826%200%200%201-.634-.302.8.8%200%200%201-.16-.67l1.029-4.981-1.12.34a.81.81%200%200%201-.86-.262.802.802%200%200%201-.165-.67l.63-3.08-2.027.468a.808.808%200%200%201-.768-.233.81.81%200%200%201-.217-.6l.389-6.57-7.44-1.33a.612.612%200%200%200-.64.906L11.58%2023.691a.612.612%200%200%200%201.066-.004l11.26-20.135a.612.612%200%200%200-.644-.9z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/reference/configuration/"
                  >Vite: choose a steady reading pace</a
                ><span
                  >Use pixel speed and viewport rules to keep a feed readable as its layout
                  changes.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EGitHub%3C%2Ftitle%3E%3Cpath%20d%3D%22M12%20.297c-6.63%200-12%205.373-12%2012%200%205.303%203.438%209.8%208.205%2011.385.6.113.82-.258.82-.577%200-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422%2018.07%203.633%2017.7%203.633%2017.7c-1.087-.744.084-.729.084-.729%201.205.084%201.838%201.236%201.838%201.236%201.07%201.835%202.809%201.305%203.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93%200-1.31.465-2.38%201.235-3.22-.135-.303-.54-1.523.105-3.176%200%200%201.005-.322%203.3%201.23.96-.267%201.98-.399%203-.405%201.02.006%202.04.138%203%20.405%202.28-1.552%203.285-1.23%203.285-1.23.645%201.653.24%202.873.12%203.176.765.84%201.23%201.91%201.23%203.22%200%204.61-2.805%205.625-5.475%205.92.42.36.81%201.096.81%202.22%200%201.606-.015%202.896-.015%203.286%200%20.315.21.69.825.57C20.565%2022.092%2024%2017.592%2024%2012.297c0-6.627-5.373-12-12-12%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/lifecycle/"
                  >GitHub: keep live content in sync</a
                ><span
                  >Add items to the original source, refresh its copies and release the controller
                  on cleanup.</span
                ></span
              >
            </div>
            <div class="item story-item">
              <span class="story-image"
                ><img
                  src="data:image/svg+xml,%3Csvg%20fill%3D%22%23e0dbe8%22%20role%3D%22img%22%20viewBox%3D%220%200%2024%2024%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Ctitle%3EFigma%3C%2Ftitle%3E%3Cpath%20d%3D%22M15.852%208.981h-4.588V0h4.588c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.491-4.49%204.491zM12.735%207.51h3.117c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-3.117V7.51zm0%201.471H8.148c-2.476%200-4.49-2.014-4.49-4.49S5.672%200%208.148%200h4.588v8.981zm-4.587-7.51c-1.665%200-3.019%201.355-3.019%203.019s1.354%203.02%203.019%203.02h3.117V1.471H8.148zm4.587%2015.019H8.148c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h4.588v8.98zM8.148%208.981c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h3.117V8.981H8.148zM8.172%2024c-2.489%200-4.515-2.014-4.515-4.49s2.014-4.49%204.49-4.49h4.588v4.441c0%202.503-2.047%204.539-4.563%204.539zm-.024-7.51a3.023%203.023%200%200%200-3.019%203.019c0%201.665%201.365%203.019%203.044%203.019%201.705%200%203.093-1.376%203.093-3.068v-2.97H8.148zm7.704%200h-.098c-2.476%200-4.49-2.014-4.49-4.49s2.014-4.49%204.49-4.49h.098c2.476%200%204.49%202.014%204.49%204.49s-2.014%204.49-4.49%204.49zm-.097-7.509c-1.665%200-3.019%201.355-3.019%203.019s1.355%203.019%203.019%203.019h.098c1.665%200%203.019-1.355%203.019-3.019s-1.355-3.019-3.019-3.019h-.098z%22%2F%3E%3C%2Fsvg%3E%0A"
                  alt="" /></span
              ><span class="story-copy"
                ><a href="https://samline.github.io/ticker/docs/guides/accessibility/"
                  >Figma: make motion accessible</a
                ><span
                  >Give keyboard users time to read with focus pause and respect reduced motion
                  preferences.</span
                ></span
              >
            </div>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
          <div role="group" aria-label="Direction">
            <button type="button" data-example-direction="up" aria-pressed="true">Up</button
            ><button type="button" data-example-direction="down" aria-pressed="false">Down</button>
          </div>
        </div>
        <p>
          Illustrative technology marks, not product announcements or endorsements. Choose Up or
          Down to change movement. Hover or focus a link to read; use Previous, Next, the rail or
          drag to browse.
        </p>
        <p>
          <a
            href="https://samline.github.io/ticker/attribution/"
            target="_blank"
            rel="noopener noreferrer"
            >Demo brand marks and attribution</a
          >
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', {
        duration: 20,
        speed: 28,
        direction: 'up',
        navigation: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        const directions = example.querySelectorAll('button[data-example-direction]')
        directions.forEach(button => {
          button.addEventListener(
            'click',
            () => {
              news.update({ direction: button.dataset.exampleDirection })
              directions.forEach(item => item.setAttribute('aria-pressed', String(item === button)))
            },
            { signal }
          )
        })

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 09

Duration and constant speed

Compare a fixed 20-second cycle with a longer sequence moving at 40 pixels per second. Speed takes precedence over duration and survives content resizing.

Fixed duration
Start with a working ticker Make motion accessible Choose your configuration
Constant pixel speed
Start with a working ticker Make motion accessible Choose your configuration Start with a working ticker Make motion accessible Choose your configuration

The first row uses duration. The second uses speed, so its duration follows the measured sequence length.

Compare speed and duration ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Duration and constant speed</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Duration and constant speed">
      <section class="ticker-recipe">
        <h1>Duration and constant speed</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Fixed duration"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Constant pixel speed"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          The first row uses duration. The second uses speed, so its duration follows the measured
          sequence length.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const tickers = [
  ticker('#ticker-1', { duration: 20, direction: 'left', pauseOnHover: true }),
  ticker('#ticker-2', { duration: 20, speed: 40, direction: 'left', pauseOnHover: true }),
].filter(Boolean)
const news = tickers[0]

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = tickers.some(item => item.state.requested)
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  tickers.forEach(news => {
    news.on('playback', renderPlayback)
  })
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = tickers.some(item => item.state.requested)
      tickers.forEach(news => {
        playing ? news.pause() : news.play()
      })
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      tickers.forEach(news => {
        news.refresh()
      })
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      tickers.forEach(news => {
        news.destroy()
      })
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Duration and constant speed</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Duration and constant speed">
      <section class="ticker-recipe">
        <h1>Duration and constant speed</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Fixed duration"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Constant pixel speed"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          The first row uses duration. The second uses speed, so its duration follows the measured
          sequence length.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const tickers = [
        ticker('#ticker-1', { duration: 20, direction: 'left', pauseOnHover: true }),
        ticker('#ticker-2', { duration: 20, speed: 40, direction: 'left', pauseOnHover: true }),
      ].filter(Boolean)
      const news = tickers[0]

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = tickers.some(item => item.state.requested)
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        tickers.forEach(news => {
          news.on('playback', renderPlayback)
        })
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = tickers.some(item => item.state.requested)
            tickers.forEach(news => {
              playing ? news.pause() : news.play()
            })
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            tickers.forEach(news => {
              news.refresh()
            })
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            tickers.forEach(news => {
              news.destroy()
            })
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Duration and constant speed</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Duration and constant speed">
      <section class="ticker-recipe">
        <h1>Duration and constant speed</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Fixed duration"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Constant pixel speed"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          The first row uses duration. The second uses speed, so its duration follows the measured
          sequence length.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const tickers = [
  ticker('#ticker-1', { duration: 20, direction: 'left', pauseOnHover: true }),
  ticker('#ticker-2', { duration: 20, speed: 40, direction: 'left', pauseOnHover: true }),
].filter(Boolean)
const news = tickers[0]

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = tickers.some(item => item.state.requested)
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  tickers.forEach(news => {
    news.on('playback', renderPlayback)
  })
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = tickers.some(item => item.state.requested)
      tickers.forEach(news => {
        playing ? news.pause() : news.play()
      })
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      tickers.forEach(news => {
        news.refresh()
      })
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      tickers.forEach(news => {
        news.destroy()
      })
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Duration and constant speed</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Duration and constant speed">
      <section class="ticker-recipe">
        <h1>Duration and constant speed</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Fixed duration"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Constant pixel speed"
          aria-live="off"
        >
          <div id="ticker-2" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          The first row uses duration. The second uses speed, so its duration follows the measured
          sequence length.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const tickers = [
        ticker('#ticker-1', { duration: 20, direction: 'left', pauseOnHover: true }),
        ticker('#ticker-2', { duration: 20, speed: 40, direction: 'left', pauseOnHover: true }),
      ].filter(Boolean)
      const news = tickers[0]

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = tickers.some(item => item.state.requested)
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        tickers.forEach(news => {
          news.on('playback', renderPlayback)
        })
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = tickers.some(item => item.state.requested)
            tickers.forEach(news => {
              playing ? news.pause() : news.play()
            })
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            tickers.forEach(news => {
              news.refresh()
            })
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            tickers.forEach(news => {
              news.destroy()
            })
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 10

RTL content and logical movement

Arabic and Hebrew text retain their writing order alongside numbers and Latin text. Automatic direction follows the inherited writing direction.

RTL mixed content
العربية 123 ABC עברית 456 XYZ Release 2.1 — 789

Playback starts automatically. The preview has an explicit RTL region inside this LTR page. Previous and Next follow reading order; left and right remain physical animation directions.

Read the writing direction contract ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>RTL content and logical movement</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="RTL content and logical movement">
      <section class="ticker-recipe">
        <h1>RTL content and logical movement</h1>
        <div
          class="window"
          dir="rtl"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="RTL mixed content"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">العربية 123 ABC</span>
            <span class="item">עברית 456 XYZ</span>
            <span class="item">Release 2.1 — 789</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Playback starts automatically. The preview has an explicit RTL region inside this LTR
          page. Previous and Next follow reading order; left and right remain physical animation
          directions.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', {
  duration: 20,
  direction: 'auto',
  navigation: true,
  autoplay: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>RTL content and logical movement</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="RTL content and logical movement">
      <section class="ticker-recipe">
        <h1>RTL content and logical movement</h1>
        <div
          class="window"
          dir="rtl"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="RTL mixed content"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">العربية 123 ABC</span>
            <span class="item">עברית 456 XYZ</span>
            <span class="item">Release 2.1 — 789</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Playback starts automatically. The preview has an explicit RTL region inside this LTR
          page. Previous and Next follow reading order; left and right remain physical animation
          directions.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', {
        duration: 20,
        direction: 'auto',
        navigation: true,
        autoplay: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>RTL content and logical movement</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="RTL content and logical movement">
      <section class="ticker-recipe">
        <h1>RTL content and logical movement</h1>
        <div
          class="window"
          dir="rtl"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="RTL mixed content"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">العربية 123 ABC</span>
            <span class="item">עברית 456 XYZ</span>
            <span class="item">Release 2.1 — 789</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Playback starts automatically. The preview has an explicit RTL region inside this LTR
          page. Previous and Next follow reading order; left and right remain physical animation
          directions.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', {
  duration: 20,
  direction: 'auto',
  navigation: true,
  autoplay: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>RTL content and logical movement</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="RTL content and logical movement">
      <section class="ticker-recipe">
        <h1>RTL content and logical movement</h1>
        <div
          class="window"
          dir="rtl"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="RTL mixed content"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">العربية 123 ABC</span>
            <span class="item">עברית 456 XYZ</span>
            <span class="item">Release 2.1 — 789</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0"
          /></label>
        </div>
        <p>
          Playback starts automatically. The preview has an explicit RTL region inside this LTR
          page. Previous and Next follow reading order; left and right remain physical animation
          directions.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', {
        duration: 20,
        direction: 'auto',
        navigation: true,
        autoplay: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 11

Responsive motion

Resize the viewport: the same content uses 45 pixels per second on desktop and 24 on narrow screens. Focus and explicit pause remain independent.

Responsive news
Start with a working ticker Make motion accessible Choose your configuration

Below 48rem the viewport rule overrides speed. Leaving the rule restores the current base speed. Reduced motion still prevents autoplay.

Configure viewport rules ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Responsive motion</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Responsive motion">
      <section class="ticker-recipe">
        <h1>Responsive motion</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Responsive news"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Below 48rem the viewport rule overrides speed. Leaving the rule restores the current base
          speed. Reduced motion still prevents autoplay.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', {
  duration: 20,
  direction: 'left',
  speed: 45,
  responsive: [{ query: '(width \u003c 48rem)', options: { speed: 24 } }],
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Responsive motion</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Responsive motion">
      <section class="ticker-recipe">
        <h1>Responsive motion</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Responsive news"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Below 48rem the viewport rule overrides speed. Leaving the rule restores the current base
          speed. Reduced motion still prevents autoplay.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', {
        duration: 20,
        direction: 'left',
        speed: 45,
        responsive: [{ query: '(width \u003c 48rem)', options: { speed: 24 } }],
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Responsive motion</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Responsive motion">
      <section class="ticker-recipe">
        <h1>Responsive motion</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Responsive news"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Below 48rem the viewport rule overrides speed. Leaving the rule restores the current base
          speed. Reduced motion still prevents autoplay.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', {
  duration: 20,
  direction: 'left',
  speed: 45,
  responsive: [{ query: '(width \u003c 48rem)', options: { speed: 24 } }],
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Responsive motion</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Responsive motion">
      <section class="ticker-recipe">
        <h1>Responsive motion</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Responsive news"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/getting-started/"
              target="_blank"
              rel="noopener noreferrer"
              >Start with a working ticker</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/guides/accessibility/"
              target="_blank"
              rel="noopener noreferrer"
              >Make motion accessible</a
            >
            <a
              class="item"
              href="https://samline.github.io/ticker/docs/reference/configuration/"
              target="_blank"
              rel="noopener noreferrer"
              >Choose your configuration</a
            >
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
        </div>
        <p>
          Below 48rem the viewport rule overrides speed. Leaving the rule restores the current base
          speed. Reduced motion still prevents autoplay.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', {
        duration: 20,
        direction: 'left',
        speed: 45,
        responsive: [{ query: '(width \u003c 48rem)', options: { speed: 24 } }],
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>
Examples / 12

Drag and choose what happens next

Drag the moving strip with a mouse or touch. Turn Resume after drag on to continue playback after release, or off to leave the strip paused.

Drag playback demo
Grab the strip Drag to explore Release to compare Switch resume on or off

With the option on, release and move the pointer away to clear hover pause. With it off, press Play before trying another drag. Changing the option does not start playback or clear a manual pause.

Read about drag playback ↗
Open code · HTML / CDN or Package / ESM

HTML / CDN opens directly as an index.html file with an internet connection; the CDN script exposes window.Ticker. For Package / ESM, install @samline/ticker and use the file in an HTML-entry bundler such as Vite. Both use the same ticker() API; a registry is optional.

Read or copy HTML, CSS and JavaScript as three files in the same folder. Use Complete file to copy one runnable index.html instead. Copied code uses the initial settings; preview changes are not included.

HTML / CDN

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Drag and choose what happens next</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Drag and choose what happens next">
      <section class="ticker-recipe">
        <h1>Drag and choose what happens next</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Drag playback demo"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Grab the strip</span>
            <span class="item">Drag to explore</span>
            <span class="item">Release to compare</span>
            <span class="item">Switch resume on or off</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0" /></label
          ><button
            type="button"
            data-example-resume
            aria-label="Resume after drag"
            aria-pressed="true"
          >
            Resume after drag <span data-resume-state aria-hidden="true">On</span>
          </button>
        </div>
        <p>
          With the option on, release and move the pointer away to clear hover pause. With it off,
          press Play before trying another drag. Changing the option does not start playback or
          clear a manual pause.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
const ticker = window.Ticker.ticker

const news = ticker('#ticker-1', {
  duration: 20,
  speed: 40,
  direction: 'left',
  navigation: true,
  resumeAfterDrag: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  const resumeButton = example.querySelector('[data-example-resume]')
  resumeButton.addEventListener(
    'click',
    () => {
      const enabled = !news.options.resumeAfterDrag
      news.update({ resumeAfterDrag: enabled })
      resumeButton.setAttribute('aria-pressed', String(enabled))
      resumeButton.querySelector('[data-resume-state]').textContent = enabled ? 'On' : 'Off'
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Drag and choose what happens next</title>

    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/style.css"
    />
    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Drag and choose what happens next">
      <section class="ticker-recipe">
        <h1>Drag and choose what happens next</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Drag playback demo"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Grab the strip</span>
            <span class="item">Drag to explore</span>
            <span class="item">Release to compare</span>
            <span class="item">Switch resume on or off</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0" /></label
          ><button
            type="button"
            data-example-resume
            aria-label="Resume after drag"
            aria-pressed="true"
          >
            Resume after drag <span data-resume-state aria-hidden="true">On</span>
          </button>
        </div>
        <p>
          With the option on, release and move the pointer away to clear hover pause. With it off,
          press Play before trying another drag. Changing the option does not start playback or
          clear a manual pause.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/@samline/ticker@2.1.0/dist/browser/global.global.js"></script>

    <script type="module">
      const ticker = window.Ticker.ticker

      const news = ticker('#ticker-1', {
        duration: 20,
        speed: 40,
        direction: 'left',
        navigation: true,
        resumeAfterDrag: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        const resumeButton = example.querySelector('[data-example-resume]')
        resumeButton.addEventListener(
          'click',
          () => {
            const enabled = !news.options.resumeAfterDrag
            news.update({ resumeAfterDrag: enabled })
            resumeButton.setAttribute('aria-pressed', String(enabled))
            resumeButton.querySelector('[data-resume-state]').textContent = enabled ? 'On' : 'Off'
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

Package / ESM

HTML

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Drag and choose what happens next</title>

    <link rel="stylesheet" href="./style.css" />
  </head>

  <body>
    <main aria-label="Drag and choose what happens next">
      <section class="ticker-recipe">
        <h1>Drag and choose what happens next</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Drag playback demo"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Grab the strip</span>
            <span class="item">Drag to explore</span>
            <span class="item">Release to compare</span>
            <span class="item">Switch resume on or off</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0" /></label
          ><button
            type="button"
            data-example-resume
            aria-label="Resume after drag"
            aria-pressed="true"
          >
            Resume after drag <span data-resume-state aria-hidden="true">On</span>
          </button>
        </div>
        <p>
          With the option on, release and move the pointer away to clear hover pause. With it off,
          press Play before trying another drag. Changing the option does not start playback or
          clear a manual pause.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module" src="./main.js"></script>
  </body>
</html>

CSS

style.css
body {
  margin: 0;
  font:
    1rem/1.6 system-ui,
    sans-serif;
}
.ticker-recipe {
  --bg: #faf9fc;
  --text: #292233;
  --surface: #ffffff;
  --border: #c6bdcf;
  --accent: #69518d;
  --up: #306344;
  --down: #a74337;
  --hover: #e0d5ed;
  display: block;
  padding: 1rem;
  background: var(--bg);
  color: var(--text);
}
@media (prefers-color-scheme: dark) {
  .ticker-recipe {
    --bg: #1d1c23;
    --text: #e5e2eb;
    --surface: #292731;
    --border: #474250;
    --accent: #b7aed2;
    --up: #b4c8bb;
    --down: #d5aea5;
    --hover: #443a4f;
  }
}
@scope (.ticker-recipe) {
  h1 {
    margin: 0 0 1rem;
    font-size: 1.125rem;
    font-weight: 500;
  }
  .window {
    overflow-x: auto;
    margin-block: 0.75rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.75rem;
    background: var(--surface);
  }
  [data-row] {
    display: flex;
    align-items: center;
    gap: 1.5rem;
    width: max-content;
  }
  .ticker-track {
    gap: 1.5rem;
    padding-block: 1rem;
  }
  .item {
    display: inline-flex;
    align-items: center;
    gap: 0.625rem;
    white-space: nowrap;
    padding-inline: 0.25rem;
  }
  a {
    color: var(--accent);
    text-underline-offset: 0.1875rem;
  }
  img {
    width: 2rem;
    height: 2rem;
    object-fit: contain;
  }
  .up {
    color: var(--up);
  }
  .down {
    color: var(--down);
  }
  .controls {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    margin-top: 1rem;
  }
  button,
  select {
    font: inherit;
    padding: 0.375rem 0.75rem;
    background: var(--surface);
    color: var(--text);
    border: 0.0625rem solid var(--border);
    border-radius: 0.5rem;
    cursor: pointer;
  }
  @media (hover: hover) {
    button:hover,
    select:hover {
      background: var(--hover);
    }
  }
  select {
    max-width: 100%;
    min-width: 0;
  }
  label {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
  }
  input {
    accent-color: var(--accent);
    width: 6rem;
  }
  output {
    font-variant-numeric: tabular-nums;
  }
  p {
    margin: 0.75rem 0 0;
    font-size: 0.8125rem;
  }
  p:empty {
    display: none;
  }
  [hidden] {
    display: none !important;
  }
  :focus-visible {
    outline: 0.125rem solid var(--accent);
    outline-offset: 0.1875rem;
  }
  .window:focus-visible {
    outline-offset: -0.1875rem;
  }
  .window[data-vertical='true'] .ticker-wrapper {
    height: 22rem;
  }
  .window[data-vertical='true'] [data-row] {
    flex-direction: column;
    width: 100%;
    align-items: stretch;
    gap: 1rem;
  }
  .window[data-vertical='true'] .ticker-track {
    gap: 1rem;
    padding-block: 0.5rem;
  }
  .window[data-vertical='true'] .item {
    white-space: normal;
  }
  .story-item {
    display: grid;
    grid-template-columns: 3.5rem minmax(0, 1fr);
    align-items: start;
    gap: 1rem;
    width: 100%;
    box-sizing: border-box;
    padding: 1rem;
    border: 0.0625rem solid var(--border);
    border-radius: 1rem;
    background: var(--surface);
  }
  .story-image {
    display: grid;
    place-items: center;
    width: 3.5rem;
    height: 3.5rem;
    border: 0.0625rem solid var(--border);
    border-radius: 0.625rem;
  }
  .story-image img {
    width: 1.75rem;
    height: 1.75rem;
    filter: brightness(0.55) saturate(1.3);
  }
  .story-copy {
    display: grid;
    min-width: 0;
    gap: 0.5rem;
    font-size: 0.8125rem;
    line-height: 1.6;
  }
  .story-copy a {
    font-size: 0.875rem;
  }
  @media (prefers-color-scheme: dark) {
    .story-image img {
      filter: none;
    }
  }
}
@media (prefers-reduced-motion: reduce) {
  .ticker-recipe [data-pause] {
    display: none;
  }
}

JavaScript

main.js
import { ticker } from '@samline/ticker'
import '@samline/ticker/style.css'

const news = ticker('#ticker-1', {
  duration: 20,
  speed: 40,
  direction: 'left',
  navigation: true,
  resumeAfterDrag: true,
  pauseOnHover: true,
})

if (news) {
  const lifecycle = new AbortController()
  const { signal } = lifecycle
  const example = document.querySelector('.ticker-recipe')
  const pauseButton = example.querySelector('[data-pause]')
  const renderPlayback = () => {
    const playing = news.state.requested
    example.dataset.paused = String(!playing)
    pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
  }
  news.on('playback', renderPlayback)
  renderPlayback()
  example.querySelector('.controls').hidden = false
  pauseButton.addEventListener(
    'click',
    () => {
      const playing = news.state.requested
      playing ? news.pause() : news.play()
    },
    { signal }
  )

  news.connectControls({
    previous: example.querySelector('[data-previous]'),
    next: example.querySelector('[data-next]'),
    rail: example.querySelector('[data-position]'),
  })

  const resumeButton = example.querySelector('[data-example-resume]')
  resumeButton.addEventListener(
    'click',
    () => {
      const enabled = !news.options.resumeAfterDrag
      news.update({ resumeAfterDrag: enabled })
      resumeButton.setAttribute('aria-pressed', String(enabled))
      resumeButton.querySelector('[data-resume-state]').textContent = enabled ? 'On' : 'Off'
    },
    { signal }
  )

  document.fonts.ready.then(() => {
    if (!signal.aborted) {
      news.refresh()
    }
  })
  window.addEventListener(
    'pagehide',
    event => {
      if (event.persisted) return
      lifecycle.abort()
      news.destroy()
    },
    { signal }
  )
}

Complete file

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Drag and choose what happens next</title>

    <style>
      body {
        margin: 0;
        font:
          1rem/1.6 system-ui,
          sans-serif;
      }
      .ticker-recipe {
        --bg: #faf9fc;
        --text: #292233;
        --surface: #ffffff;
        --border: #c6bdcf;
        --accent: #69518d;
        --up: #306344;
        --down: #a74337;
        --hover: #e0d5ed;
        display: block;
        padding: 1rem;
        background: var(--bg);
        color: var(--text);
      }
      @media (prefers-color-scheme: dark) {
        .ticker-recipe {
          --bg: #1d1c23;
          --text: #e5e2eb;
          --surface: #292731;
          --border: #474250;
          --accent: #b7aed2;
          --up: #b4c8bb;
          --down: #d5aea5;
          --hover: #443a4f;
        }
      }
      @scope (.ticker-recipe) {
        h1 {
          margin: 0 0 1rem;
          font-size: 1.125rem;
          font-weight: 500;
        }
        .window {
          overflow-x: auto;
          margin-block: 0.75rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.75rem;
          background: var(--surface);
        }
        [data-row] {
          display: flex;
          align-items: center;
          gap: 1.5rem;
          width: max-content;
        }
        .ticker-track {
          gap: 1.5rem;
          padding-block: 1rem;
        }
        .item {
          display: inline-flex;
          align-items: center;
          gap: 0.625rem;
          white-space: nowrap;
          padding-inline: 0.25rem;
        }
        a {
          color: var(--accent);
          text-underline-offset: 0.1875rem;
        }
        img {
          width: 2rem;
          height: 2rem;
          object-fit: contain;
        }
        .up {
          color: var(--up);
        }
        .down {
          color: var(--down);
        }
        .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
        }
        button,
        select {
          font: inherit;
          padding: 0.375rem 0.75rem;
          background: var(--surface);
          color: var(--text);
          border: 0.0625rem solid var(--border);
          border-radius: 0.5rem;
          cursor: pointer;
        }
        @media (hover: hover) {
          button:hover,
          select:hover {
            background: var(--hover);
          }
        }
        select {
          max-width: 100%;
          min-width: 0;
        }
        label {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
        }
        input {
          accent-color: var(--accent);
          width: 6rem;
        }
        output {
          font-variant-numeric: tabular-nums;
        }
        p {
          margin: 0.75rem 0 0;
          font-size: 0.8125rem;
        }
        p:empty {
          display: none;
        }
        [hidden] {
          display: none !important;
        }
        :focus-visible {
          outline: 0.125rem solid var(--accent);
          outline-offset: 0.1875rem;
        }
        .window:focus-visible {
          outline-offset: -0.1875rem;
        }
        .window[data-vertical='true'] .ticker-wrapper {
          height: 22rem;
        }
        .window[data-vertical='true'] [data-row] {
          flex-direction: column;
          width: 100%;
          align-items: stretch;
          gap: 1rem;
        }
        .window[data-vertical='true'] .ticker-track {
          gap: 1rem;
          padding-block: 0.5rem;
        }
        .window[data-vertical='true'] .item {
          white-space: normal;
        }
        .story-item {
          display: grid;
          grid-template-columns: 3.5rem minmax(0, 1fr);
          align-items: start;
          gap: 1rem;
          width: 100%;
          box-sizing: border-box;
          padding: 1rem;
          border: 0.0625rem solid var(--border);
          border-radius: 1rem;
          background: var(--surface);
        }
        .story-image {
          display: grid;
          place-items: center;
          width: 3.5rem;
          height: 3.5rem;
          border: 0.0625rem solid var(--border);
          border-radius: 0.625rem;
        }
        .story-image img {
          width: 1.75rem;
          height: 1.75rem;
          filter: brightness(0.55) saturate(1.3);
        }
        .story-copy {
          display: grid;
          min-width: 0;
          gap: 0.5rem;
          font-size: 0.8125rem;
          line-height: 1.6;
        }
        .story-copy a {
          font-size: 0.875rem;
        }
        @media (prefers-color-scheme: dark) {
          .story-image img {
            filter: none;
          }
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .ticker-recipe [data-pause] {
          display: none;
        }
      }
    </style>
  </head>

  <body>
    <main aria-label="Drag and choose what happens next">
      <section class="ticker-recipe">
        <h1>Drag and choose what happens next</h1>
        <div
          class="window"
          dir="ltr"
          data-vertical="false"
          tabindex="0"
          role="region"
          aria-label="Drag playback demo"
          aria-live="off"
        >
          <div id="ticker-1" data-row>
            <span class="item">Grab the strip</span>
            <span class="item">Drag to explore</span>
            <span class="item">Release to compare</span>
            <span class="item">Switch resume on or off</span>
          </div>
        </div>
        <div class="controls" hidden>
          <button type="button" data-pause>Pause animation</button>
          <button type="button" data-previous>Previous items</button
          ><button type="button" data-next>Next items</button>
          <label
            >Position in repeated sequence
            <input type="range" data-position min="0" max="1" value="0" /></label
          ><button
            type="button"
            data-example-resume
            aria-label="Resume after drag"
            aria-pressed="true"
          >
            Resume after drag <span data-resume-state aria-hidden="true">On</span>
          </button>
        </div>
        <p>
          With the option on, release and move the pointer away to clear hover pause. With it off,
          press Play before trying another drag. Changing the option does not start playback or
          clear a manual pause.
        </p>
        <p role="status" aria-live="polite"></p>
        <noscript
          ><p>Motion needs JavaScript. Scroll to read all the original content.</p></noscript
        >
      </section>
    </main>

    <script type="module">
      import { ticker } from '@samline/ticker'
      import '@samline/ticker/style.css'

      const news = ticker('#ticker-1', {
        duration: 20,
        speed: 40,
        direction: 'left',
        navigation: true,
        resumeAfterDrag: true,
        pauseOnHover: true,
      })

      if (news) {
        const lifecycle = new AbortController()
        const { signal } = lifecycle
        const example = document.querySelector('.ticker-recipe')
        const pauseButton = example.querySelector('[data-pause]')
        const renderPlayback = () => {
          const playing = news.state.requested
          example.dataset.paused = String(!playing)
          pauseButton.textContent = playing ? 'Pause animation' : 'Play animation'
        }
        news.on('playback', renderPlayback)
        renderPlayback()
        example.querySelector('.controls').hidden = false
        pauseButton.addEventListener(
          'click',
          () => {
            const playing = news.state.requested
            playing ? news.pause() : news.play()
          },
          { signal }
        )

        news.connectControls({
          previous: example.querySelector('[data-previous]'),
          next: example.querySelector('[data-next]'),
          rail: example.querySelector('[data-position]'),
        })

        const resumeButton = example.querySelector('[data-example-resume]')
        resumeButton.addEventListener(
          'click',
          () => {
            const enabled = !news.options.resumeAfterDrag
            news.update({ resumeAfterDrag: enabled })
            resumeButton.setAttribute('aria-pressed', String(enabled))
            resumeButton.querySelector('[data-resume-state]').textContent = enabled ? 'On' : 'Off'
          },
          { signal }
        )

        document.fonts.ready.then(() => {
          if (!signal.aborted) {
            news.refresh()
          }
        })
        window.addEventListener(
          'pagehide',
          event => {
            if (event.persisted) return
            lifecycle.abort()
            news.destroy()
          },
          { signal }
        )
      }
    </script>
  </body>
</html>

These examples show a few possibilities. The next ideas are yours: combine the package’s capabilities with your own content, design and interactions to create the experience your project needs.

New to Ticker? Start with installation ↗ Building with an AI agent? Download the Ticker skill ↗ Demo brand marks and attribution ↗
@samline/ticker

A small, framework-agnostic library for moving content across the open web.

v2.1.0 · MIT

Explore

Docs Installation guide Working examples

Project

Ticker skill for AI agents GitHub ↗ (opens in a new tab) npm ↗ (opens in a new tab) License ↗ (opens in a new tab)

© 2026 Samuel Olvera · Made for the open web.

Back to top ↑