Design gallery
Motion and effects

Typed rotator

Types a phrase, holds, deletes it and types the next, with a blinking caret.

I build fast websites., smooth motion., clean interfaces.

Code 1 file

src/gallery/typed-rotator/TypedRotator.astroAstro67 lines
---
// Types a phrase, holds, deletes, types the next, with a blinking caret.
// The full sentence is in the DOM for screen readers and for no-JS visitors.
// Reduced motion: the first phrase stays put.
interface Props { prefix?: string; words?: string[] }
const { prefix = 'I build', words = ['fast websites.', 'smooth motion.', 'clean interfaces.'] } = Astro.props;
---
<div class="rotator-wrap">
  <p class="big" aria-hidden="true">{prefix} <span class="role" data-rotator data-words={JSON.stringify(words)}>{words[0]}</span><span class="caret"></span></p>
  <p class="visually-hidden">{prefix} {words.join(', ')}</p>
</div>

<style>
  .rotator-wrap {
    --rot-accent: #9fd09c;
    position: absolute; inset: 0; display: grid; place-items: center; padding: 1.5rem;
    container-type: size; color: #f1f1ef; text-align: center;
  }
  .big { font-size: clamp(1.75rem, 7cqi, 4.5rem); line-height: 1.04; letter-spacing: -0.04em; }
  .role { white-space: nowrap; color: var(--rot-accent); }
  .caret {
    display: inline-block; width: 0.06em; height: 0.85em; margin-left: 0.06em;
    background: var(--rot-accent); vertical-align: -0.05em;
    animation: blink 1s steps(1) infinite;
  }
  @keyframes blink { 50% { opacity: 0; } }
  @media (prefers-reduced-motion: reduce) { .caret { animation: none; } }
</style>

<script>
  const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

  // Types a phrase, holds, deletes, types the next. Words come from data-words (JSON array).
  // Runs only while the element is on screen.
  function initRotator(el: HTMLElement) {
    const words: string[] = JSON.parse(el.dataset.words ?? '[]');
    if (!words.length || reduce) return;
    let w = 0, c = words[0].length, deleting = false;
    let timer = 0, running = false;
    const next = (ms: number) => { timer = window.setTimeout(tick, ms); };
    const tick = () => {
      const word = words[w];
      if (!deleting) {
        c++;
        el.textContent = word.slice(0, c);
        if (c >= word.length) { deleting = true; return next(2200); }
        return next(55 + Math.random() * 45);
      }
      c--;
      el.textContent = word.slice(0, c);
      if (c <= 0) { deleting = false; w = (w + 1) % words.length; return next(350); }
      next(28);
    };
    new IntersectionObserver(([e]) => {
      if (e?.isIntersecting && !running) {
        running = true;
        // Start by deleting the current phrase after a pause, so it opens on a full sentence.
        timer = window.setTimeout(() => { deleting = true; tick(); }, 1600);
      } else if (!e?.isIntersecting && running) {
        running = false;
        clearTimeout(timer);
      }
    }).observe(el);
  }

  document.querySelectorAll<HTMLElement>('[data-rotator]').forEach(initRotator);
</script>