Reactで、文字が横に流れ続けるお知らせを作る方法
お知らせの文字を横に流し続けるには、同じお知らせのリストを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つ並べ、半分だけ動かしてつなぎ目をなくす
流れる部分の CSS は次のとおりです。.ticker-viewport が表示枠で、はみ出した部分を隠します。その中の .ticker-track に同じ内容の <ul> を2つ横に並べ、@keyframes で translateX(0) から translateX(-50%) まで動かします。
.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 から、長いお知らせの部分だけを抜き出しています。
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} />;
}
コンポーネントの中身です。
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 スクロールできました。





