Reactで、予定を.icsファイルにしてダウンロードする方法
予定をカレンダーに取り込んでもらうには、iCalendar 形式(RFC 5545)の文字列を作り、Blob にして、拡張子 .ics のファイルとしてダウンロードさせます。文字列を作る部分は、ライブラリを入れずに書けます。
ただし、書き方には決まりがいくつかあります。日時の書き方、終日の予定の終わりの日、カンマや改行の書き方、長い行の折り返しです。ここでは、予定を入力して「カレンダーに追加(.ics)」を押すとファイルが保存されるコンポーネントを作り、保存されたファイルを iCalendar のパーサー(ical.js)で読み直して、入力どおりに戻るかを確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0、ical.js 2.2.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でボタンを押して、保存されたファイルの中身を調べました。ブラウザのタイムゾーンは Asia/Tokyo と America/New_York で試しています。Google カレンダー、Apple のカレンダー、Outlook などのアプリに実際に取り込む確認はしていません。Firefox と Safari、スマートフォンのブラウザでも試していません。

.ics ファイルに書く行
Asia/Tokyo のブラウザで、上の画像の入力のままボタンを押して保存されたファイルが、次の内容です。1行ずつ JSON の文字列として出し、かっこの中に、その行の UTF-8 でのバイト数を書きました。JSON の文字列なので、ファイルの中の \ 1つが \\ と2つ重ねて表示されています。UID と DTSTAMP は保存のたびに変わるので、<…> に置き換えています。
"BEGIN:VCALENDAR" (15)
"VERSION:2.0" (11)
"PRODID:-//example.com//Event Download//JA" (41)
"CALSCALE:GREGORIAN" (18)
"BEGIN:VEVENT" (12)
"UID:<ランダムなUUID>@example.com" (37)
"DTSTAMP:<保存した時刻>" (28)
"DTSTART:20261120T010000Z" (24)
"DTEND:20261120T023000Z" (22)
"SUMMARY:React 勉強会" (23)
"LOCATION:会議室A\\, 3階" (26)
"DESCRIPTION:持ち物:ノートPC\\n資料は当日配ります" (64)
"END:VEVENT" (10)
"END:VCALENDAR" (13)
最後が \r\n で終わる: true / \r\n 以外の改行: false
全体を BEGIN:VCALENDAR と END:VCALENDAR で囲み、その中の BEGIN:VEVENT から END:VEVENT までが1つの予定です。RFC 5545 では、カレンダーの側に PRODID(ファイルを作ったソフトを示す識別子)と VERSION、予定の側に UID と DTSTAMP が必須です。
UID は予定を見分けるための値で、世界で重ならないものにします。RFC 5545 は、@ の右にドメイン名を置く書き方を勧めています。ここでは crypto.randomUUID() で作った UUID の後ろに @example.com を付けました。PRODID と UID の example.com は、実際には自分のサイトのドメインに置き換えます。randomUUID() は、安全なコンテキスト(HTTPS や localhost で開いたページなど)でしか使えません。DTSTAMP は、METHOD の行がないファイルでは、予定の情報を最後に更新した日時を表します。ここでは、ファイルを作った時刻を入れました。
行の区切りは CRLF(\r\n)にします。文字コードは UTF-8 で、RFC 5545 でも UTF-8 が既定です。
予定を .ics の文字列にする関数
文字列を作る toIcs と、保存する downloadIcs を ics.ts にまとめました。
export type CalendarEvent = {
uid: string;
title: string;
start: Date;
end: Date;
allDay: boolean;
location: string;
description: string;
};
const pad = (n: number) => String(n).padStart(2, '0');
// UTC の日時:20261120T010000Z
function utcDateTime(d: Date): string {
return `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
}
// 終日の予定の日付:20261120(ブラウザのタイムゾーンの日付)
function localDate(d: Date): string {
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
}
// 文字列の値の \ ; , と改行を、\ を付けた形にする
function escapeText(s: string): string {
return s
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n');
}
// 1行を75バイト以内に折り返す。続きの行は空白1つで始める。
// 文字の途中(UTF-8 のバイトの途中)では切らない
const encoder = new TextEncoder();
function foldLine(line: string): string {
const parts: string[] = [];
let current = '';
let bytes = 0;
for (const ch of line) {
const size = encoder.encode(ch).length;
const limit = parts.length === 0 ? 75 : 74; // 続きの行は先頭の空白で1バイト使う
if (bytes + size > limit) {
parts.push(current);
current = '';
bytes = 0;
}
current += ch;
bytes += size;
}
parts.push(current);
return parts.join('\r\n ');
}
export function toIcs(event: CalendarEvent, now = new Date()): string {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//example.com//Event Download//JA',
'CALSCALE:GREGORIAN',
'BEGIN:VEVENT',
`UID:${event.uid}`,
`DTSTAMP:${utcDateTime(now)}`,
...(event.allDay
? [`DTSTART;VALUE=DATE:${localDate(event.start)}`, `DTEND;VALUE=DATE:${localDate(event.end)}`]
: [`DTSTART:${utcDateTime(event.start)}`, `DTEND:${utcDateTime(event.end)}`]),
`SUMMARY:${escapeText(event.title)}`,
...(event.location ? [`LOCATION:${escapeText(event.location)}`] : []),
...(event.description ? [`DESCRIPTION:${escapeText(event.description)}`] : []),
'END:VEVENT',
'END:VCALENDAR',
];
return lines.map(foldLine).join('\r\n') + '\r\n';
}
export function downloadIcs(ics: string, filename: string) {
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
ここから、関数の中で気をつけた点を順に見ていきます。
日時は UTC に直して、末尾に Z を付ける
DTSTART(開始)と DTEND(終了)は、20261120T010000Z のように、UTC の日時の後ろに Z を付けて書きました。RFC 5545 では、末尾の Z が「UTC の日時」を表します。Z もタイムゾーンの指定(TZID)もない日時は、特定のタイムゾーンに結びつかない「floating」な日時になり、受け取った人がいるタイムゾーンの時刻として扱うことになっています。
入力欄の日付と時刻は、new Date('2026-11-20T10:00') のようにつないで Date にしています。時差を書かない日時の文字列は、ブラウザのタイムゾーンの日時として読まれます。それを getUTCHours() などで UTC に直せば、どのタイムゾーンで見ても、同じ瞬間を指す予定になります。
同じ「11月20日 10:00〜11:30」を、America/New_York のブラウザで保存すると、次の行になりました。
DTSTART:20261120T150000Z
DTEND:20261120T163000Z
東京の 10:00 は UTC の 01:00、ニューヨークの 10:00 は UTC の 15:00 です。保存したファイルを ical.js で読み直すと、東京で保存したものは 2026-11-20T01:00:00.000Z から 2026-11-20T02:30:00.000Z、長さは PT1H30M(1時間30分)でした。入力したときのブラウザのタイムゾーンでの時刻どおりに入っています。
終日の予定は、終わりの日を次の日にする
「終日」にチェックを入れたときは、時刻を付けない日付(VALUE=DATE)で書きます。注意するのは DTEND です。RFC 5545 では、DTEND は予定の終わりを「その時点を含まない」形で表します。11月20日だけの終日の予定なら、終わりは11月21日です。
DTSTART;VALUE=DATE:20261120
DTEND;VALUE=DATE:20261121
ical.js で読み直すと、開始は 2026-11-20、終了は 2026-11-21、長さは P1D(1日)でした。
日付は、ブラウザのタイムゾーンの年・月・日(getFullYear() など)で書いています。UTC に直すと、東京の11月20日 0:00 は UTC の11月19日 15:00 になり、日付がずれるからです(東京の 10:00 が UTC の 01:00 になったのと同じく、9時間戻ります)。America/New_York のブラウザで保存しても、同じ 20261120 と 20261121 になりました。
カンマ・セミコロン・改行には \ を付ける
SUMMARY(タイトル)、LOCATION(場所)、DESCRIPTION(メモ)の値は、RFC 5545 の「TEXT」型です。この型では、\、;、, の前に \ を付けることが必須で、改行は \n の2文字で書きます。escapeText は、この4つを置き換えています。\ を最初に置き換えるのは、あとで付けた \ を二重にしないためです。
場所に 会議室A, 3階 と入れると、ファイルには LOCATION:会議室A\, 3階 と書かれ、ical.js で読み直すと 会議室A, 3階 に戻りました。メモの2行も、\n で1行に書かれ、読み直すと改行に戻っています。
この置き換えをしないとどうなるかも試しました。改行をそのまま入れたファイルは、ical.js で読めませんでした。2行目が、名前と値の組でない行になるからです。
ical.js のエラー: ParserError invalid line (no token ";" or ":") "資料は当日配ります"
一方、, に \ を付けずに LOCATION:会議室A, 3階 と書いたファイルは、ical.js 2.2.1 では 会議室A, 3階 と読めました。それでも RFC 5545 では \ を付けることが必須なので、どのアプリで読まれてもよいように付けています。
長い行は、75バイトごとに文字の境目で折り返す
RFC 5545 では、1行は改行を除いて75オクテット(75バイト)までにすることになっています。長い行は、CRLF の直後に空白1つを入れて、複数の行に分けます。読むときは「CRLF+空白1つ」を取り除いて、元の1行に戻します。
ひらがなやカタカナ、漢字は UTF-8 で1文字3バイトなので、文字数で数えると長さを見誤ります。foldLine は、1文字ずつ UTF-8 のバイト数を数え、75バイトを超える手前で区切っています。続きの行は先頭の空白で1バイト使うので、2行目からは74バイトまでにしました。
長いタイトルと、;・\・,・改行を含むメモを入れて保存すると、次のように折り返されました。最初の例と同じく、行を JSON の文字列として出しています。
"SUMMARY:第12回 React 勉強会(フォームとサーバーアクショ" (74)
" ンを中心に、実際にコードを書きながら学ぶ回)" (67)
"LOCATION:会議室A\\, 3階" (26)
"DESCRIPTION:持ち物:ノートPC\\; 充電器\\nパス C:\\\\work\\\\react を" (75)
" 使います\\n\\n終わったら懇親会\\, 参加は任意" (59)
SUMMARY の1行目が74バイトで止まっているのは、次の「ン」(3バイト)を足すと77バイトになるからです。ical.js で読み直したタイトルとメモは、入力した文字列と一致しました。
比べるために、文字の境目を気にせず、75バイトごとに切った場合も試しました。「ン」の3バイトの途中で切れてしまい、UTF-8 として読むと、その部分が置換文字(U+FFFD)になります。
ical.js で読んだ summary: "第12回 React 勉強会(フォームとサーバーアクショ���を中心に、実際にコードを書きながら学ぶ回)"
入力と一致: false / U+FFFD の数: 3
RFC 5545 は、文字の途中で折り返すファイルを作る実装がありうるとして、読む側に元のバイト列に戻す処理を求めています。ただ、上の検証のように、バイト列をいったん文字列に直してからパーサーに渡すと、直した時点で文字化けしてしまい、パーサーの側では元に戻せません。作る側で、文字の境目で折り返しておけば、この心配はありません。
React のコンポーネントからダウンロードさせる
コンポーネント(App.tsx)では、入力欄の値から開始と終了の Date を作り、ボタンを押したときに toIcs と downloadIcs を呼びます。次は、入力欄の JSX を省いた抜粋です。
import { useState } from 'react';
import { toIcs, downloadIcs } from './ics';
// 終日の予定の終わりは「最後の日の次の日」にする
function nextDay(date: string): Date {
const d = new Date(`${date}T00:00`);
d.setDate(d.getDate() + 1);
return d;
}
export function App() {
const [title, setTitle] = useState('React 勉強会');
const [date, setDate] = useState('2026-11-20');
const [startTime, setStartTime] = useState('10:00');
const [endTime, setEndTime] = useState('11:30');
const [allDay, setAllDay] = useState(false);
const [location, setLocation] = useState('会議室A, 3階');
const [description, setDescription] = useState('持ち物:ノートPC\n資料は当日配ります');
// 日付と時刻をつないだ文字列は、ブラウザのタイムゾーンの日時として読まれる
const start = allDay ? new Date(`${date}T00:00`) : new Date(`${date}T${startTime}`);
const end = allDay ? nextDay(date) : new Date(`${date}T${endTime}`);
const valid = title.trim() !== '' && !Number.isNaN(start.getTime()) && end > start;
const handleDownload = () => {
const ics = toIcs({
uid: `${crypto.randomUUID()}@example.com`,
title, start, end, allDay, location, description,
});
downloadIcs(ics, 'event.ics');
};
// …(入力欄の JSX は省略)
<button type="button" disabled={!valid} onClick={handleDownload}>
カレンダーに追加(.ics)
</button>
UID と DTSTAMP は保存するたびに新しくしたい値なので、.ics の文字列は描画のたびではなく、ボタンを押したときにだけ作ります。タイトルが空のときや、終了が開始より前のとき(開始 10:00、終了 09:00 など)は、ボタンを disabled にしました。どちらも Playwright で押せないことを確かめています。
保存の処理(downloadIcs)は、Blob から URL.createObjectURL() で blob: の URL を作り、download 属性を付けた a 要素をクリックさせる書き方です。download 属性に入れた event.ics が、保存されるファイルの名前になりました。Blob の type(MIME タイプ)は、RFC 5545 で決められている text/calendar にしています。使い終わった URL は URL.revokeObjectURL() で手放します。
検証環境の Chromium では、クリックの直後に revokeObjectURL() を呼んでも、ファイルは保存されました。保存に失敗したときに理由を返す Playwright の download.failure() は、どの保存でも null(失敗なし)でした。





