ScrollVars / fx
docs · demo · bench · llms.txt · GitHub

Rotating words

One word exits up, the next rises from below. A clipped column on one variable.

Use it for: Hero headlines: "We build ______".
Knobs: --sv-word (index), --sv-duration; drive it from state, scenes or an interval

we build brands, websites and products

Install: npx scrollvars add rotating-words
Styles: import 'scrollvars/styles/state.css'

<h1 class="text-5xl font-extrabold">
  we build
  <span class="sv-words text-violet-400" aria-hidden="true">
    <span>brands</span><span>websites</span><span>products</span>
  </span>
  <span class="sr-only">brands, websites and products</span>
</h1>
<button type="button" id="words-pause" aria-pressed="false">Pause rotation</button>
<!-- set --sv-word from your state; scenes drive it for free in pinned stories -->
<script>
  let paused = false
  document.getElementById('words-pause').addEventListener('click', () => {
    paused = !paused
    document.getElementById('words-pause').setAttribute('aria-pressed', String(paused))
  })
  // guard your own --sv-word write the same way: if (!paused) el.style.setProperty(...)
</script>
<h1>we build
  <span class="sv-words" aria-hidden="true">
    <span>brands</span><span>websites</span><span>products</span>
  </span>
  <!-- the column reads as three words in a row to a screen reader: hide it and say the phrase once -->
  <span style="position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap">brands, websites and products</span>
</h1>
<!-- WCAG 2.2.2: text that auto-rotates past 5s needs a pause control -->
<button type="button" id="words-pause" aria-pressed="false">Pause rotation</button>

/* the preset (styles/state.css): */
.sv-words { display: inline-flex; flex-direction: column;
  vertical-align: baseline; height: 1.15em; overflow: hidden; }
.sv-words > * { display: block; height: 1.15em; line-height: 1.15;
  translate: 0 calc(var(--sv-word, 0) * -1.15em);
  transition: translate .65s var(--sv-ease, cubic-bezier(.28,.84,.42,1)); }
/* the same sheet's reduced-motion override, last so it wins on source order: */
@media (prefers-reduced-motion: reduce) {
  .sv-words > * { transition-duration: .01ms; }
}
/* the same under html[data-sv-motion="reduce"], the site's own switch */
:where([data-sv-motion="reduce"]) .sv-words > * { transition-duration: .01ms; }

// drive it (state, scenes, or a timer), and stop on request:
let paused = false
document.getElementById('words-pause').addEventListener('click', () => {
  paused = !paused
  document.getElementById('words-pause').setAttribute('aria-pressed', String(paused))
})
if (!paused) el.style.setProperty('--sv-word', nextIndex)
const [word, setWord] = useState(0)
<h1>we build{' '}
  <span className="sv-words" style={{ '--sv-word': word } as React.CSSProperties} aria-hidden="true">
    {words.map(w => <span key={w}>{w}</span>)}
  </span>
  {/* the column reads as three words in a row to a screen reader: say the phrase once */}
  <span className="sr-only">{words.join(', ')}</span>
</h1>

Engine: npm i scrollvars, 12.9 KB gzip as ESM (this page's fx/sv.js IIFE: 13.1 KB). All effects respect prefers-reduced-motion and render complete without JS.