Reactで、日付ごとに区切ったタイムラインを表示する方法

​​React​CSS,Intl.DateTimeFormat,Playwright,TypeScript,アクセシビリティ

日時の付いた記録を日付ごとに区切って表示するには、まず記録を新しい順に並べます。次に、それぞれの日時から「どの日か」を表す文字列を取り出し、同じ日が続く間を1つのグループにまとめます。グループごとに <section> を作り、日付の見出しと記録のリストを入れれば、タイムラインになります。

いちばん間違えやすいのは、日付の取り出し方です。toISOString() の先頭10文字を使うと、日本時間の朝9時より前の記録が前の日に入ります。この記事では、タイムゾーンを決めた Intl.DateTimeFormat で日付を取り出し、サーバーとブラウザのどちらで動かしても同じ区切りになる形にしました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、日付の計算は Node.js で、画面は Playwright で Chromium を操作して確かめました。Firefox と Safari、スマートフォンのブラウザ、スクリーンリーダーでの読み上げは試していません。

タイムラインの画面。上端に「2026年11月3日(火)」の見出しが固定され、その下に13:30、07:15の記録が並び、16:20の記録は見出しに隠れかけている。続いて「2026年11月2日(月)」と「2026年11月1日(日)」の見出しと、それぞれの記録がある。左側に水色の縦線と、記録ごとの丸が並ぶ
少し下へスクロールしたときのタイムライン。11月3日の見出しが画面の上に残っている

日付は、タイムゾーンを決めて取り出す

検証には、2026年11月1日から4日までの12件の記録を使いました。日時は 2026-11-04T08:30:00+09:00 のように、日本時間のオフセット付きの文字列で持たせ、わざと順番をばらばらにしてあります。

ts
import type { Entry } from './group';

// わざと順番をばらばらにしてある
export const entries: Entry[] = [
  { id: 'a1', at: '2026-11-03T21:40:00+09:00', text: '記事の下書きを保存しました' },
  { id: 'a2', at: '2026-11-04T08:30:00+09:00', text: 'コメントに返信しました' },
  // …(全部で12件)
];

この日時から日付を取り出す方法を3つ比べました。toISOString().slice(0, 10)、getFullYear()・getMonth()・getDate() を組み合わせる方法、そして後で示す dayKey です。Node.js を、環境変数 TZ でタイムゾーンを変えて動かした結果は次のとおりです。

text
--- TZ=Asia/Tokyo ---
2026-11-04T00:20:00+09:00  toISOString: 2026-11-03  getDate: 2026-11-04  dayKey(Asia/Tokyo): 2026-11-04
2026-11-04T08:30:00+09:00  toISOString: 2026-11-03  getDate: 2026-11-04  dayKey(Asia/Tokyo): 2026-11-04
2026-11-03T07:15:00+09:00  toISOString: 2026-11-02  getDate: 2026-11-03  dayKey(Asia/Tokyo): 2026-11-03
2026-11-02T00:10:00+09:00  toISOString: 2026-11-01  getDate: 2026-11-02  dayKey(Asia/Tokyo): 2026-11-02
2026-11-01T23:50:00+09:00  toISOString: 2026-11-01  getDate: 2026-11-01  dayKey(Asia/Tokyo): 2026-11-01
--- TZ=UTC ---
2026-11-04T00:20:00+09:00  toISOString: 2026-11-03  getDate: 2026-11-03  dayKey(Asia/Tokyo): 2026-11-04
2026-11-04T08:30:00+09:00  toISOString: 2026-11-03  getDate: 2026-11-03  dayKey(Asia/Tokyo): 2026-11-04
2026-11-03T07:15:00+09:00  toISOString: 2026-11-02  getDate: 2026-11-02  dayKey(Asia/Tokyo): 2026-11-03
2026-11-02T00:10:00+09:00  toISOString: 2026-11-01  getDate: 2026-11-01  dayKey(Asia/Tokyo): 2026-11-02
2026-11-01T23:50:00+09:00  toISOString: 2026-11-01  getDate: 2026-11-01  dayKey(Asia/Tokyo): 2026-11-01

toISOString() は、試した3つのタイムゾーンのどれでも、9時より前の記録が前の日の日付になりました。MDN の toISOString() の説明にあるとおり、このメソッドが返す文字列は、いつも UTC(末尾が Z)で表した日時です。日本時間の11月4日8時30分は、UTC では11月3日23時30分なので、先頭10文字は 2026-11-03 になります。

getDate() などは、動かしている環境のタイムゾーンで日付を返します。日本時間の環境では正しい日付でしたが、TZ=UTC では toISOString() と同じ結果になりました。TZ=America/New_York でも同じでした。同じ記録でも、動かす環境のタイムゾーンによって区切りが変わります。TZ が違うだけで結果が変わったので、サーバーレンダリングでサーバーとブラウザのタイムゾーンが違えば、両者で区切りがずれます。

dayKey は、3つのタイムゾーンのどれで動かしても、日本時間の日付を返しました。

Intl.DateTimeFormat の formatToParts で、年・月・日を取り出す

dayKey と、記録をまとめる groupByDay は、React から切り離した関数にしました。

ts
export type Entry = { id: string; at: string; text: string };

export type DayGroup<T> = { key: string; items: T[] };

// timeZone で見たときの日付を "2026-11-04" の形で返す
export function dayKey(date: Date, fmt: Intl.DateTimeFormat): string {
  const parts = fmt.formatToParts(date);
  const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)!.value;
  return `${get('year')}-${get('month')}-${get('day')}`;
}

// 新しい順に並べてから、同じ日付が続く間を1つのグループにまとめる
export function groupByDay<T extends { at: string }>(entries: T[], timeZone: string): DayGroup<T>[] {
  const fmt = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' });
  const sorted = [...entries].sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
  const groups: DayGroup<T>[] = [];
  for (const entry of sorted) {
    const key = dayKey(new Date(entry.at), fmt);
    const last = groups.at(-1);
    if (last && last.key === key) {
      last.items.push(entry);
    } else {
      groups.push({ key, items: [entry] });
    }
  }
  return groups;
}

// "2026-11-04" を「2026年11月4日(水)」にする
const dayLabel = new Intl.DateTimeFormat('ja-JP', { timeZone: 'UTC', year: 'numeric', month: 'long', day: 'numeric', weekday: 'short' });
export function formatDay(key: string): string {
  const [y, m, d] = key.split('-').map(Number);
  return dayLabel.format(new Date(Date.UTC(y, m - 1, d)));
}

Intl.DateTimeFormat の timeZone には、'Asia/Tokyo' のような IANA のタイムゾーン名を渡せます。省いたときは、動かしている環境のタイムゾーンになります(MDN の Intl.DateTimeFormat() コンストラクターの説明)。

formatToParts() は、整形した文字列を、type と value を持つオブジェクトの配列に分けて返します。dayKey は、その中から year、month、day の値を type で探して、- でつなぎます。'en-US' で整形すると 11/04/2026 になり、部分の type は month、literal、day、literal、year の順に並びます。値を type で拾うので、並び順や区切りの文字には左右されません。month と day に '2-digit' を指定しているので、2026-11-04 のように桁がそろい、文字列のまま比べられます。

見出しの文字にする formatDay では、timeZone を 'UTC' にしています。2026-11-04 はもう決まった日付なので、Date.UTC でその日の UTC の0時を作り、UTC のまま文字列にすれば、どの環境でも同じ日付と曜日になります。検証環境では、2026-11-04 が「2026年11月4日(水)」、2027-01-01 が「2027年1月1日(金)」になりました。

新しい順に並べてから、続く同じ日付をまとめる

groupByDay は、先に日時で並べ替えてから、1件ずつ見ていきます。直前のグループと同じ日付ならそのグループに足し、違えば新しいグループを作ります。並べ替えたあとなら、同じ日付の記録はかならず続いて並ぶので、これで日付ごとにまとまります。sort() は配列そのものを並べ替えるので、props で受け取った配列を変えないように、[...entries] で写してから並べています。

12件を groupByDay(entries, 'Asia/Tokyo') に渡した結果は次のとおりです。

text
2026年11月4日(水) 2026-11-04 12:05, 08:30, 00:20
2026年11月3日(火) 2026-11-03 21:40, 16:20, 13:30, 07:15
2026年11月2日(月) 2026-11-02 18:00, 09:45, 00:10
2026年11月1日(日) 2026-11-01 23:50, 10:00

4日分のグループに分かれ、日付も記録も新しい順に並びました。並べ替えをせずに、同じ処理でまとめたものとも比べました。

text
グループの数: 12
2026-11-03(1) 2026-11-04(1) 2026-11-02(1) 2026-11-04(1) 2026-11-03(1) 2026-11-01(1) 2026-11-04(1) 2026-11-02(1) 2026-11-03(1) 2026-11-01(1) 2026-11-02(1) 2026-11-03(1)

検証のデータは、同じ日付が1件も続かない順に並べてあるため、12件が12個のグループになり、同じ日付の見出しが何度も出ました。新しい順で届くはずのデータでも、並べ替えを関数の中に入れておけば、順番が崩れて届いたときも区切りが乱れません。

日付ごとに section と見出しを作る

コンポーネントは次のとおりです。記録の配列を entries で受け取り、タイムゾーンは timeZone で変えられるようにしました。

tsx
import { useId, useMemo } from 'react';
import { formatDay, groupByDay, type Entry } from './group';

type Props = { entries: Entry[]; timeZone?: string };

export function Timeline({ entries, timeZone = 'Asia/Tokyo' }: Props) {
  const id = useId();
  const groups = useMemo(() => groupByDay(entries, timeZone), [entries, timeZone]);
  const time = useMemo(
    () => new Intl.DateTimeFormat('ja-JP', { timeZone, hour: '2-digit', minute: '2-digit' }),
    [timeZone],
  );

  return (
    <div className="timeline">
      {groups.map((group) => (
        <section key={group.key} aria-labelledby={`${id}-${group.key}`}>
          <h2 id={`${id}-${group.key}`} className="day">
            <time dateTime={group.key}>{formatDay(group.key)}</time>
          </h2>
          <ol>
            {group.items.map((entry) => (
              <li key={entry.id}>
                <time dateTime={entry.at}>{time.format(new Date(entry.at))}</time>
                <p>{entry.text}</p>
              </li>
            ))}
          </ol>
        </section>
      ))}
    </div>
  );
}

グループごとの <section> には、aria-labelledby で日付の見出しを名前として付けました。MDN の region ロールの説明によると、<section> はアクセシブルな名前が与えられると、region ロールとして伝えられます。見出しの id は、useId で作った文字列に日付を足したものです。同じページに Timeline を2つ置いても、id がぶつかりません。

日付と時刻は <time> 要素に入れ、dateTime に機械が読める値を書きました。MDN の time 要素の説明にあるとおり、datetime 属性には 2026-11-04 のような日付の文字列を書けます。記録の時刻のほうには、データの日時の文字列をそのまま入れています。

Chromium で開き、Playwright の ariaSnapshot() で、最初の <section> がどう伝わるかを見ました。

text
- region "2026年11月4日(水)":
  - heading "2026年11月4日(水)" [level=2]:
    - time: 2026年11月4日(水)
  - list:
    - listitem:
      - time: 12:05
      - paragraph: 記事を公開しました
    - listitem:
      - time: 08:30
      - paragraph: コメントに返信しました
    - listitem:
      - time: 00:20
      - paragraph: 目次を直しました

4つの <section> は、どれも日付の名前が付いた region になりました。getByRole('region', { name: '2026年11月3日(火)' }) のように、日付の名前で1つずつ見つけられます。見出しの id は _r_0_-2026-11-04 のような形でした。

timeZone を決めているので、動かす場所が変わっても表示は変わりません。Node.js の renderToString で、プロセスのタイムゾーンを Asia/Tokyo、UTC、America/New_York に変えて描画したところ、3つの HTML は、長さも SHA-256 の値(先頭16桁)も一致しました。Playwright でブラウザ側のタイムゾーン(timezoneId)を同じ3つに変えて開いても、見出し、日ごとの件数(3、4、3、2件)、12件すべての時刻が同じでした。サーバーとブラウザで日付の表示がずれる問題そのものは、「Reactで日付を表示するとき、サーバーとブラウザのずれを防ぐ方法」の記事で詳しく扱っています。

日付の見出しを、その日の間だけ画面の上に残す

スクロールしても今どの日を見ているかが分かるように、日付の見出しに position: sticky を付けました。CSS は次のとおりです。

css
body { margin: 0; background: #fff; color: #1f2a30; font-family: "IPAGothic", sans-serif; }
.timeline { max-width: 360px; margin: 0 auto; }
.day {
  position: sticky; top: 0; z-index: 1; margin: 0; padding: 8px 12px;
  background: #eefafd; color: #067a98; font-size: 16px;
  border-bottom: 1px solid #00c8e8;
}
.timeline ol { list-style: none; margin: 0 0 0 20px; padding: 4px 12px 16px 20px; border-left: 2px solid #00c8e8; }
.timeline li { position: relative; padding: 8px 0; }
.timeline li::before {
  content: ''; position: absolute; left: -28px; top: 13px;
  width: 10px; height: 10px; border-radius: 50%; background: #fff; border: 2px solid #00c8e8;
}
.timeline li time { font-size: 13px; color: #555f66; }
.timeline li p { margin: 2px 0 0; }

MDN の position の説明によると、sticky の要素は、包含ブロックが top などで決めた位置に達するまでは普通に配置されます。達した後は、包含ブロックの反対の端が来るまで、その位置に留まります。見出しは <section> の中にあるので、その日の記録が画面にある間だけ上に残ります。記事の冒頭の画像は、高さ 520px の画面で 273px スクロールしたときのものです。11月3日の見出しが上に残り、その背景が、上へ流れていく16時20分の記録を隠しています。

見出しには背景色と z-index: 1 を付けています。記録の li は、丸(li::before)を置くために position: relative にしてあり、HTML の中で見出しより後にあります。そのため z-index がないと、スクロールした記録が見出しの上に描かれます。検証環境で見出しの z-index を外し、見出しの場所で、いちばん上に描かれている要素を elementFromPoint で調べると、スクロール量 230px、250px、290px のどれでも、見出しではなく記録の li や p でした。z-index: 1 を付けた記事の CSS では、3つとも見出しでした。

検証環境で、高さ 300px の画面でスクロールの位置を変え、4つの見出しの位置(画面の上からの距離)を測りました。表には11月2日までを載せています。2つ目の <section>(11月3日)は、ページの 203px から 456px の範囲にあり、見出しの高さは 33px です。

スクロール量 11月4日 11月3日 11月2日 画面の上端にある見出し
0px 0 203 456 11月4日
100px 0 103 356 11月4日
203px −33 0 253 11月3日
413px −243 0 43 11月3日
436px −266 −13 20 11月3日
461px −291 −38 0 11月2日

スクロール量が 100px のとき、11月4日の見出しは上に残っています。11月3日の <section> が上に来た 203px では、11月4日の見出しは 33px 上へ出て見えなくなり、11月3日の見出しが画面の上端に着きました。436px では、11月3日の <section> の終わりが近づいて、その見出しが 13px 上へ押し出されています。見出しどうしが重なることはなく、次の日の見出しが前の日の見出しを押し上げる形で入れ替わりました。

​Posted by 86