Reactで、文字が横に流れ続けるお知らせを作る方法

​​React​CSS,Playwright,ResizeObserver,TypeScript,アクセシビリティ

お知らせの文字を横に流し続けるには、同じお知らせのリストを2つ横に並べ、全体を CSS アニメーションで幅の半分だけ左へ動かすことをくり返します。半分動いたところの見た目は動き始めと同じなので、くり返しのつなぎ目で表示が飛びません。React で受け持つのは、お知らせの幅を測って流す速さを決めることと、一時停止ボタンの状態です。

この記事のコードは、本番ビルドを Chromium で開き、流れる速さ、つなぎ目、止め方、Tab キーでの移動、動きを減らす設定を確かめたものです。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、表示幅 400px のページを Playwright で操作しました。StrictMode を付けた開発ビルドでも、操作して React の警告が出ないことを確かめています。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げ、Next.js などのフレームワークの上での動きは試していません。

水色の枠の中で、お知らせの文字が右から左へ流れている。カーソルを文字の上に乗せると止まり、離すとまた流れる。右の「一時停止」ボタンを押すと止まってボタンの文字が「再生」になり、もう一度押すと流れ出す。左下の「流れている」「止まっている」の表示は、切り替わった瞬間だけピンクの枠で囲まれる
マウスを乗せたときと、一時停止ボタンを2回押したときの動き。カーソル、押した場所の輪、右下の操作の説明、左下の状態の表示は GIF 用に重ねたもの

同じリストを2つ並べ、半分だけ動かしてつなぎ目をなくす

流れる部分の CSS は次のとおりです。.ticker-viewport が表示枠で、はみ出した部分を隠します。その中の .ticker-track に同じ内容の <ul> を2つ横に並べ、@keyframes で translateX(0) から translateX(-50%) まで動かします。

css
.ticker { display: flex; align-items: center; gap: 8px; padding: 6px 8px;
  background: #eefafd; border: 1px solid #00c8e8; border-radius: 6px; }
.ticker-viewport { flex: 1; min-width: 0; overflow: hidden; }
.ticker-track { display: flex; width: max-content; }
.ticker-track[data-scrolling="true"] { animation: ticker var(--ticker-duration) linear infinite; }
/* 一時停止ボタンを押したときと、マウスを乗せている間は、その位置で止める */
.ticker-track[data-paused="true"],
.ticker-viewport:hover .ticker-track { animation-play-state: paused; }
/* 中のリンクにフォーカスがある間は、流れる前の位置に戻し、枠のスクロールでリンクを見せる */
.ticker-viewport:focus-within .ticker-track { animation: none; }
/* 同じ内容を2つ並べているので、半分(1つ分)動かすと最初と同じ見た目に戻る */
@keyframes ticker { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.ticker-list { display: flex; flex: none; margin: 0; padding: 0; list-style: none; }
.ticker-list li { padding-right: 3em; white-space: nowrap; }
.ticker-toggle { flex: none; font: inherit; padding: 2px 10px; }

@media (prefers-reduced-motion: reduce) {
  .ticker-track[data-scrolling="true"] { animation: none; }
  .ticker-viewport { overflow-x: auto; }
  .ticker-list[aria-hidden="true"], .ticker-toggle { display: none; }
}

お知らせとお知らせの間は、li の右側の padding で空けています。リストとリストの間に別の余白を入れないことで、.ticker-track の幅がちょうどリスト2つ分になり、-50% がリスト1つ分の距離になります。

検証環境で試したところ、リスト1つの幅は 1091.2px、.ticker-track の幅は 2182.4px でした。アニメーションの最後のコマでは、複製のほうのリストが、動き始めの元のリストと同じ位置(ページの左端から 25px。表示枠の左端ちょうど)に来ていました。表示枠のスクリーンショットも、動き始めと最後のコマで一致しました。

幅を測り、枠に収まらないときだけ流す

使い方は次のとおりです。お知らせは { id, text, href } の配列で渡し、href があればリンクにします。検証に使った App.tsx から、長いお知らせの部分だけを抜き出しています。

tsx
import { NewsTicker, type News } from './NewsTicker';

const LONG: News[] = [
  { id: '1', text: '11月3日(火)はサポート窓口をお休みします', href: '#holiday' },
  { id: '2', text: '新しい料金プランの受付を始めました', href: '#plan' },
  { id: '3', text: '11月10日 2:00〜4:00 にメンテナンスを行います', href: '#maintenance' },
];

export function App() {
  return <NewsTicker items={LONG} />;
}

コンポーネントの中身です。

tsx
import { useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent } from 'react';

export type News = { id: string; text: string; href?: string };

type Props = {
  items: News[];
  // 1秒間に流れる距離(px)。お知らせの長さが変わっても、流れる速さは変わらない
  speed?: number;
};

export function NewsTicker({ items, speed = 60 }: Props) {
  const viewportRef = useRef<HTMLDivElement>(null);
  const listRef = useRef<HTMLUListElement>(null);
  const [listWidth, setListWidth] = useState(0);
  const [overflowing, setOverflowing] = useState(false);
  const [paused, setPaused] = useState(false);

  // お知らせの幅と表示枠の幅を測り、枠に収まらないときだけ流す
  useLayoutEffect(() => {
    const viewport = viewportRef.current!;
    const list = listRef.current!;
    const measure = () => {
      const w = list.getBoundingClientRect().width;
      setListWidth(w);
      setOverflowing(w > viewport.clientWidth);
    };
    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(viewport);
    observer.observe(list);
    return () => observer.disconnect();
  }, []);

  // フォーカスしたリンクの頭が枠の左端に来るように、枠を横にスクロールする
  const handleFocus = (e: FocusEvent<HTMLDivElement>) => {
    const viewport = e.currentTarget;
    viewport.scrollLeft += e.target.getBoundingClientRect().left - viewport.getBoundingClientRect().left;
  };

  // フォーカスが枠の外へ出たら、スクロールを元に戻す
  const handleBlur = (e: FocusEvent<HTMLDivElement>) => {
    if (!e.currentTarget.contains(e.relatedTarget)) e.currentTarget.scrollLeft = 0;
  };

  const renderItems = () =>
    items.map((item) => (
      <li key={item.id}>{item.href ? <a href={item.href}>{item.text}</a> : item.text}</li>
    ));

  return (
    <section className="ticker" aria-label="お知らせ">
      <div ref={viewportRef} className="ticker-viewport" onFocus={handleFocus} onBlur={handleBlur}>
        <div
          className="ticker-track"
          data-scrolling={overflowing}
          data-paused={paused}
          style={{ '--ticker-duration': `${listWidth / speed}s` } as CSSProperties}
        >
          <ul ref={listRef} className="ticker-list">
            {renderItems()}
          </ul>
          {/* つなぎ目なく流すための複製。読み上げとキーボード操作の対象から外す */}
          {overflowing && (
            <ul className="ticker-list" aria-hidden="true" inert>
              {renderItems()}
            </ul>
          )}
        </div>
      </div>
      {overflowing && (
        <button type="button" className="ticker-toggle" onClick={() => setPaused((p) => !p)}>
          {paused ? '再生' : '一時停止'}
        </button>
      )}
    </section>
  );
}

アニメーションの長さ(秒)は、リスト1つの幅を speed(1秒に何 px 進むか)で割って決めます。長さを決め打ちにすると、お知らせが長いほど速く流れてしまうためです。検証環境では、幅 1091.2px のリストでアニメーションの長さが 18.1867s になり、1秒間に 61.0px 動きました(1秒待つ間の誤差を含みます)。速さを変えたいときは <NewsTicker items={LONG} speed={40} /> のように渡しますが、検証は既定の 60 だけで行いました。

幅は useLayoutEffect の中で測っています。React の公式リファレンスにあるuseLayoutEffect の説明のとおり、ここで測って state を更新すると、ブラウザが画面を描く前に React がもう一度レンダリングします。そのあとの幅の変化は ResizeObserver で検知します。表示枠とリストの両方を見ているので、リストの幅が変わったときも知らせが来る作りです。検証したのは表示幅を変えた場合だけで、お知らせを差し替えた場合は試していません。

リストが表示枠に収まるときは、流さずにそのまま表示します。流れる必要のない短い文まで動かすと、読みにくくなるだけだからです。表示幅 400px のページで、短いお知らせ(幅 287.5px、枠 350px)は流れず、複製と一時停止ボタンも出ませんでした。長いお知らせのほうも、表示幅を 1300px に広げると枠が 1250px になって流れが止まり、400px に戻すとまた流れ出しました。

止める手段は、マウスのほかにボタンでも用意する

マウスを乗せている間は、animation-play-state: paused でその位置に止めます。MDN の animation-play-state の説明にあるとおり、止めたアニメーションは、止めた位置から再開します。検証環境では、マウスを乗せている間は 0.5秒たっても 0.0px しか動かず、離すと 0.5秒で 30.0px 動きました。

ただし、マウスを乗せられない人もいます。キーボードだけで操作する人がそうです。WCAG 2.2 の達成基準 2.2.2 では、自動で始まり、5秒より長く動き、ほかの内容と並んで表示されるものには、利用者が一時停止・停止・非表示にできる手段を求めています。その解説「Understanding Pause, Stop, Hide」では、フォーカスがある間だけ止まり、離すとまた動き出す作りは、この手段にならないとしています。

そこで、リストの右に「一時停止」ボタンを置きました。押すと data-paused が "true" になって止まり、ボタンの文字が「再生」に変わります。もう一度押すと流れ出します。

ポイント

マウスを乗せたときに止めるだけでは足りません。止めたままにできるボタンを、流れる部分の外に置きます。

複製のリストは aria-hidden と inert で読み上げと Tab キーの移動先から外す

つなぎ目のために並べた複製のリストは、画面の見た目のためだけのものです。そのままにすると、スクリーンリーダーで同じお知らせが2回読まれ、Tab キーでも同じリンクに2回止まります。

そのため、複製の <ul> には aria-hidden="true" と inert を付けました。MDN の aria-hidden の説明では、フォーカスを受け取れる要素やその親に aria-hidden="true" を付けないように書かれています。複製の中のリンクはフォーカスを受け取れるので、inert 属性でフォーカスもクリックもできないようにしています。検証した React 19.3.1 では、inert を JSX に真偽値の属性として書け、型チェックも通りました。

Playwright でアクセシビリティツリーを出したところ、region "お知らせ" の中に出てくるリンクは3つで、それぞれ1回だけでした。Tab キーでたどると、3つのリンクのあとは一時停止ボタンに進み、複製の中のリンクには止まりませんでした。

キーボードでリンクに移ったときは、流れる前の位置に戻す

リンクを Tab キーで選んだときに文字が流れていると、どれを選んでいるのか読めません。そこで、表示枠の中にフォーカスがある間は、:focus-within で animation: none にしてリストを動き始めの位置に戻しています。そのうえで、onFocus の中で表示枠の scrollLeft を変え、フォーカスしたリンクの左端を枠の左端に合わせます。overflow: hidden の枠でも、scrollLeft を変えれば中身を横にずらせます。

検証環境で試したところ、Tab キーで2つ目、3つ目のリンクに移ると scrollLeft が 379、699 になり、どちらもリンクの左端が表示枠の左端(0px の位置)に来ました。一時停止ボタンへ進むと animation-name が ticker に戻って、また流れ出しました。次のリンクへ進んだあとに測ると、onBlur で表示枠の scrollLeft は 0 に戻っていました。

動きを減らす設定のときは流さず、横にスクロールできるようにする

OS で動きを減らす設定にしている人には、prefers-reduced-motion の reduce が当てはまります。上の CSS の最後の @media で、この設定のときはアニメーションを止め、表示枠を overflow-x: auto にして、複製と一時停止ボタンを隠しています。

Playwright で reducedMotion: 'reduce' にしたところ、animation-name は none、複製とボタンは display: none になりました。表示枠は幅 350px に対して中身が 1091px で、横に 150px スクロールできました。

​Posted by 86