TECH · Web Components
ビルドの要らない部品化
Web Componentsのサンプル集。customElementsの最小構成、Shadow DOMによるスタイル隔離、slotでの差し込み、属性の監視とプロパティの扱いを、フレームワークを1つも使わずに収録します。
サンプルとコード
Shadow DOMを使わない25行ほどの部品です。日時を相対表記に置き換えます。中身に元の表記を書いておけば、スクリプトが動かなくても意味が残ります。
HTML
<div class="card">
<h2>投稿されたコメント</h2>
<p>この機能、助かりました。ありがとうございます。</p>
<!-- 中身に元の表記を書いておけば、JS が動かなくても意味が残る -->
<span class="log">投稿: <relative-time datetime="2026-08-29T10:00:00Z">2026年8月29日</relative-time></span>
</div>
<div class="card">
<h2>前回のログイン</h2>
<p>別の端末からのアクセスがありました。</p>
<span class="log">日時: <relative-time datetime="2026-08-31T21:30:00Z">2026年8月31日</relative-time></span>
</div> CSS
/* Shadow DOM を使わないカスタム要素には、外側の CSS がそのまま届く。
既存のデザインシステムに馴染ませたいときはこちらが楽。 */
relative-time {
display: inline-block;
font-variant-numeric: tabular-nums;
}
/* 定義が終わるまでは :defined が付かない。
定義前に中身が空で見えるのを防ぐには、この形で隠しておく。 */
relative-time:not(:defined) { visibility: hidden; }
.card {
background: #fff; border: 1px solid var(--line);
border-radius: 12px; padding: 1rem 1.1rem;
}
.card h2 { margin: 0 0 .4rem; font-size: 1rem; line-height: 1.6; }
.card p { margin: 0; color: var(--muted); line-height: 1.85; font-size: .9rem; }
.log { display: block; margin-top: .6rem; font-size: .84rem; color: var(--accent); line-height: 1.8; } JavaScript
// HTMLElement を継承したクラスを作り、タグ名を登録するだけ。
// タグ名にはハイフンが必須(標準のタグと衝突させないため)。
class RelativeTime extends HTMLElement {
// 要素が文書に挿入されたときに呼ばれる。
// ここで初めて DOM を触るのが約束事。constructor では触らない。
connectedCallback() {
this.render();
}
render() {
const iso = this.getAttribute("datetime");
const t = iso ? new Date(iso) : null;
if (!t || Number.isNaN(t.getTime())) return; // 中身は元の表記のまま残す
const diff = (Date.now() - t.getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat("ja", { numeric: "auto" });
const table = [
[60, "second", 1],
[3600, "minute", 60],
[86400, "hour", 3600],
[2592000, "day", 86400],
];
const row = table.find(([limit]) => diff < limit);
this.textContent = row
? rtf.format(-Math.round(diff / row[2]), row[1])
: t.toLocaleDateString("ja-JP");
// 元の絶対時刻はツールチップと支援技術のために残す
this.title = t.toLocaleString("ja-JP");
}
}
customElements.define("relative-time", RelativeTime); AIへの指示文
日時を「3日前」のような相対表記に置き換えるカスタム要素を作ってください。
要件:
- HTMLElement を継承したクラスを customElements.define で登録する。
タグ名にはハイフンが必須である理由をコメントで書く。
- DOM を触るのは connectedCallback の中。constructor では触らない。
- Shadow DOM は使わず、外側の CSS が効く形にする。
- 中身には元の絶対表記を書いておき、スクリプトが動かない場合や
datetime が不正な場合はそれをそのまま残す。
- Intl.RelativeTimeFormat("ja") を使い、秒・分・時間・日で単位を切り替える。
- 元の絶対時刻は title 属性に入れて、支援技術とツールチップに残す。
- :not(:defined) で定義前の状態を隠す方法もコメントで示す。
外側に「本文を赤・2remにする」乱暴な指定を置いてあります。説明文は実際に大きくなりますが、カードは影響を受けません。外から変えたい部分だけカスタムプロパティで穴を開けています。
HTML
<stat-card class="good" label="継続率" value="94%" note="直近30日"></stat-card>
<stat-card label="導入社数" value="1,280" note="前月比 +42"></stat-card>
<stat-card class="warn" label="保存容量" value="残り 8%" note="整理を推奨"></stat-card> CSS
/* この宣言は Shadow DOM の内側には届かない。
「壊されない部品」を作るとき、これが最大の利点になる。 */
.danger-zone p { color: #dc2626 !important; font-size: 2rem !important; }
/* 外から見た目を変えたいところだけ、カスタムプロパティで穴を開ける。
変数は Shadow 境界を越えて継承されるので、これが唯一の作法。 */
stat-card { --card-accent: #2563eb; }
stat-card.warn { --card-accent: #d97706; }
stat-card.good { --card-accent: #0f7b53; }
/* ::part で指定された部分だけは、外から直接触れる */
stat-card::part(value) { letter-spacing: 0; } JavaScript
// テンプレートは1度だけ作って全インスタンスで使い回す。
// 要素ごとに innerHTML を組み立てるより速い。
const tpl = document.createElement("template");
tpl.innerHTML = `
<style>
/* このスタイルは外へ漏れず、外からも壊されない。
:host はカスタム要素そのものを指す。 */
:host {
display: block;
background: #fff;
border: 1px solid #e2e6ee;
border-left: 4px solid var(--card-accent, #5a6478);
border-radius: 12px;
padding: 0.9rem 1.1rem;
font-family: inherit; /* フォントは継承させたいので明示的に受ける */
}
.label { font-size: 0.82rem; color: #5a6478; line-height: 1.7; }
.value {
font-size: 1.5rem; font-weight: 800; line-height: 1.4;
color: var(--card-accent, #1b2030);
font-variant-numeric: tabular-nums;
}
.note { font-size: 0.8rem; color: #98a2b5; line-height: 1.8; }
</style>
<div class="label"></div>
<div class="value" part="value"></div>
<div class="note"></div>
`;
class StatCard extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return; // 二重描画を防ぐ
const root = this.attachShadow({ mode: "open" });
root.append(tpl.content.cloneNode(true));
root.querySelector(".label").textContent = this.getAttribute("label") ?? "";
root.querySelector(".value").textContent = this.getAttribute("value") ?? "";
root.querySelector(".note").textContent = this.getAttribute("note") ?? "";
}
}
customElements.define("stat-card", StatCard); AIへの指示文
Shadow DOM でスタイルを隔離した統計カードを作ってください。
要件:
- attachShadow({ mode: "open" }) を使い、テンプレートは1度だけ作って
全インスタンスで使い回す(要素ごとに innerHTML を組むより速い)。
- :host でカスタム要素自身を装飾する。font-family: inherit を明示して
フォントだけは外から継承させる。
- 外側に強い指定(!important 付き)を置き、Shadow の中身が
影響を受けないことを目で確認できるようにする。
- 外から変えたい色はカスタムプロパティで受ける
(var(--card-accent, 既定値))。変数は Shadow 境界を越えて継承される。
- 一部の要素に part 属性を付け、::part で外から触れる例も示す。
- connectedCallback の冒頭で shadowRoot の有無を確認し、二重描画を防ぐ。
器と装飾は部品側、中身は使う側。差し込まれた要素には外側のCSSがそのまま効くので、本文の書き味を保ったまま枠だけ共通化できます。
HTML
<note-box tone="info">
<span slot="title">補足</span>
<p>名前付きの <code>slot</code> は複数置けます。指定しなかった中身は、既定の(名前なしの)slot にまとめて入ります。</p>
</note-box>
<note-box tone="warn">
<span slot="title">注意</span>
<p>Shadow DOM の中身と違い、slot に差し込まれた要素は<strong>外側の CSS が効きます</strong>。上の <code>note-box p</code> の指定がそのまま当たっています。</p>
<p>これは欠点ではなく設計です。器は部品が持ち、文章の体裁はページ側が持つ、という分担になります。</p>
</note-box>
<note-box>
<!-- slot="title" を省略すると、部品側の既定値が使われる -->
<p>タイトルを省略すると、部品に書いた既定のテキストが出ます。<code><slot></code> の中身がそのまま代替値になる仕組みです。</p>
</note-box> CSS
/* slot に差し込まれた中身は「外側の DOM のまま」なので、
外側の CSS がそのまま効く。Shadow の内側とは扱いが違う。 */
note-box p { margin: 0 0 .5rem; line-height: 1.85; }
note-box p:last-child { margin-bottom: 0; }
note-box a { color: var(--accent); }
note-box code {
background: rgba(27, 32, 48, .07);
padding: .1em .35em; border-radius: 5px; font-size: .92em;
} JavaScript
const tpl = document.createElement("template");
tpl.innerHTML = `
<style>
:host {
display: block;
border-radius: 12px;
padding: 0.9rem 1.1rem;
border: 1px solid var(--tone-line, #e2e6ee);
background: var(--tone-bg, #fff);
font-family: inherit;
color: inherit;
}
:host([tone="info"]) { --tone-bg: #eff6ff; --tone-line: #bfdbfe; --tone-ink: #1e40af; }
:host([tone="warn"]) { --tone-bg: #fffbeb; --tone-line: #fde68a; --tone-ink: #92400e; }
.head {
display: flex; align-items: baseline; gap: .5rem;
margin-bottom: .45rem;
font-weight: 700; line-height: 1.6;
color: var(--tone-ink, #1b2030);
}
.mark { font-size: .9em; }
/* ::slotted は差し込まれた要素の「一番外側」だけに当たる。
入れ子の子孫には届かないので、細かい装飾は外側の CSS に任せる。 */
::slotted(p:first-of-type) { margin-top: 0; }
</style>
<div class="head">
<span class="mark" aria-hidden="true">●</span>
<slot name="title">メモ</slot>
</div>
<slot></slot>
`;
class NoteBox extends HTMLElement {
connectedCallback() {
if (this.shadowRoot) return;
this.attachShadow({ mode: "open" }).append(tpl.content.cloneNode(true));
}
}
customElements.define("note-box", NoteBox); AIへの指示文
slot を使って中身を差し込めるメモ枠の部品を作ってください。
要件:
- 名前付き slot(name="title")と既定の slot の2つを持たせる。
- slot の中に既定値を書き、省略されたときはそれが表示されるようにする。
- :host([tone="info"]) のように属性セレクタで配色を切り替える。
- slot に差し込まれた中身は外側の DOM のままなので外側の CSS が効く、
という違いをコメントで明記する。
- ::slotted() は差し込まれた要素の一番外側にしか当たらず、
子孫には届かないことも書く。
- 使用例では、外側の CSS で本文の余白やリンク色を指定して、
実際に効いていることが分かるようにする。
observedAttributes に列挙した属性だけが変更通知されます。属性は文字列しか持てないので、真偽値は存在の有無で表し、数値はプロパティ側で受けるのが定石です。
HTML
<progress-ring value="42" label="アップロード"></progress-ring>
<div class="row">
<button class="btn" type="button" data-set="10">10%</button>
<button class="btn" type="button" data-set="42">42%</button>
<button class="btn" type="button" data-set="88">88%</button>
<button class="btn" type="button" data-set="100">100%</button>
<button class="btn" type="button" id="toggle-compact">compact を切替</button>
</div>
<pre class="out" id="out"></pre> JavaScript
const tpl = document.createElement("template");
tpl.innerHTML = `
<style>
:host { display: flex; align-items: center; gap: 1rem; font-family: inherit; }
:host([compact]) { gap: .6rem; }
:host([compact]) svg { width: 44px; height: 44px; }
svg { width: 76px; height: 76px; flex: none; }
.track { stroke: #e2e6ee; }
.bar {
stroke: #2563eb; stroke-linecap: round;
transition: stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1);
}
.txt { font-size: .95rem; font-weight: 700; line-height: 1.5; }
.lbl { font-size: .84rem; color: #5a6478; line-height: 1.8; }
:host([compact]) .lbl { display: none; }
@media (prefers-reduced-motion: reduce) { .bar { transition: none; } }
</style>
<svg viewBox="0 0 100 100" role="img">
<circle class="track" cx="50" cy="50" r="42" fill="none" stroke-width="10"></circle>
<circle class="bar" cx="50" cy="50" r="42" fill="none" stroke-width="10"
transform="rotate(-90 50 50)"></circle>
</svg>
<div>
<div class="txt"></div>
<div class="lbl"></div>
</div>
`;
class ProgressRing extends HTMLElement {
// 監視したい属性をここに列挙する。書き忘れると変更が届かない。
static observedAttributes = ["value", "label", "compact"];
connectedCallback() {
if (!this.shadowRoot) {
this.attachShadow({ mode: "open" }).append(tpl.content.cloneNode(true));
}
this.render();
}
// 属性が変わるたびに呼ばれる。connectedCallback より先に呼ばれることも
// あるので、shadowRoot の有無を必ず確認してから触る。
attributeChangedCallback() {
if (this.shadowRoot) this.render();
}
// 属性は文字列。数値として扱う窓口をプロパティで用意しておくと、
// JS からは el.value = 60 と書けて読みやすくなる。
get value() { return Number(this.getAttribute("value") ?? 0); }
set value(v) { this.setAttribute("value", String(v)); }
// 真偽値は「属性があるかどうか」で表す。value="false" は
// 文字列 "false" が入るだけで真になってしまうので使わない。
get compact() { return this.hasAttribute("compact"); }
set compact(on) { this.toggleAttribute("compact", Boolean(on)); }
render() {
const pct = Math.max(0, Math.min(100, this.value));
const circumference = 2 * Math.PI * 42;
const bar = this.shadowRoot.querySelector(".bar");
bar.style.strokeDasharray = String(circumference);
bar.style.strokeDashoffset = String(circumference * (1 - pct / 100));
this.shadowRoot.querySelector(".txt").textContent = pct + "%";
this.shadowRoot.querySelector(".lbl").textContent = this.getAttribute("label") ?? "";
this.shadowRoot.querySelector("svg")
.setAttribute("aria-label", (this.getAttribute("label") ?? "進捗") + " " + pct + "%");
}
}
customElements.define("progress-ring", ProgressRing);
// --- 動作確認 ---
const ring = document.querySelector("progress-ring");
const out = document.getElementById("out");
const log = () =>
(out.textContent =
"属性 value: " + JSON.stringify(ring.getAttribute("value")) + "(文字列)\n" +
"プロパティ .value: " + ring.value + "(数値)\n" +
"属性 compact: " + (ring.hasAttribute("compact") ? "あり" : "なし") + "\n" +
"プロパティ .compact: " + ring.compact);
document.querySelectorAll("[data-set]").forEach((b) =>
b.addEventListener("click", () => { ring.value = Number(b.dataset.set); log(); }),
);
document.getElementById("toggle-compact").addEventListener("click", () => {
ring.compact = !ring.compact;
log();
});
log(); AIへの指示文
属性の変更に追従する円形の進捗表示コンポーネントを作ってください。
要件:
- static observedAttributes に監視したい属性を列挙する。
書き忘れると変更が届かない点をコメントで書く。
- attributeChangedCallback は connectedCallback より先に呼ばれることが
あるので、shadowRoot の有無を確認してから DOM を触る。
- 数値は get/set プロパティで受け、JS からは el.value = 60 と
書けるようにする。属性は文字列しか持てないことを説明する。
- 真偽値は属性の有無で表す(hasAttribute と toggleAttribute)。
value="false" は文字列として真になるので使わない、と明記する。
- 進捗は SVG の circle と stroke-dasharray / stroke-dashoffset で描く。
- svg に aria-label を付け、値が変わるたびに更新する。
- prefers-reduced-motion では transition を切る。
この技術について
Web Componentsは、ブラウザに元から入っている部品化の仕組みです。customElements.define でタグを登録し、Shadow DOM でスタイルを閉じ込め、slot で中身を差し込む。この3つだけで、どのフレームワークからも使える部品が作れます。
派手さはありません。開発体験も、いまどきのフレームワークに比べれば素朴です。それでも選ぶ理由は一つで、ビルドが要らず依存も増えないこと。5年後に触っても同じコードが動いている可能性が、圧倒的に高い。
まず、Shadow DOM を使わない選択肢がある
最小構成は驚くほど短く、25行ほどで成立します。この記事の最初のサンプル——日時を「3日前」に置き換える部品——は Shadow DOM を使っていません。
使わないと外側のCSSがそのまま届くので、既存のデザインシステムに馴染ませたいときはむしろ楽です。Shadow DOM の利点は「壊されない」ことですが、裏返すと「外から直せない」ことでもあります。サイト内で完結する部品なら使わず、外へ配布する部品なら使う。この切り分けが実務的です。
もう一つ、最小構成で守っておきたい作法があります。DOMを触るのは connectedCallback の中で、constructor では触らない。そして中身に意味のある代替表現を書いておくこと。スクリプトが失敗しても、元の日付表記が残っていれば情報は失われません。
閉じ込めたうえで、穴を開ける
Shadow DOM を使うと、外側のCSSは中へ届かなくなります。この記事のサンプルでは、外側に「本文を赤・2remにする」という乱暴な指定をわざと置いてあります。説明文は実際に巨大になりますが、カードは何の影響も受けません。
ただし完全に閉じてしまうと、配色すら変えられない部品になります。そこで外から変えたいところだけカスタムプロパティで穴を開けます。CSS変数は Shadow 境界を越えて継承されるので、var(--card-accent, 既定値) と書いておけば、使う側は変数を上書きするだけで色を変えられる。構造まで触らせたい部分には part 属性を付け、::part() で外から指定できるようにします。
slot に差し込まれた中身の扱いは、また別です。あれは外側のDOMのままなので、外側のCSSがそのまま効きます。器は部品が持ち、文章の体裁はページ側が持つ、という分担になります。
属性は文字列しか持てない
実装で最も事故が多いのが、属性とプロパティの区別です。
属性はHTMLに書く初期値、プロパティはJavaScriptから読み書きする窓口。そして属性は文字列しか持てません。数値を受けるなら get value() { return Number(this.getAttribute("value")) } のようにプロパティ側で変換します。
真偽値は特に危険です。disabled="false" と書いても、属性が存在する以上は「ある」ので真になります。標準のHTML要素と同じく、存在するかどうかで表してください。hasAttribute と toggleAttribute を使えば自然にそうなります。
変更に追従させたい属性は static observedAttributes に列挙します。ここに書き忘れると attributeChangedCallback が呼ばれません。そして、このコールバックは connectedCallback より先に呼ばれることがあります。DOMを触る前に shadowRoot の有無を確認する一行を入れておくと、原因の分かりにくいエラーを避けられます。
"false" と書くと真になるのが典型的な落とし穴で、外すことで偽を表します。定義前の一瞬をどうするか
スクリプトの読み込みが終わるまで、カスタム要素はただの未知のタグです。中身がそのまま表示され、レイアウトが崩れて見えることがあります。
対処は2通り。:not(:defined) セレクタで一時的に隠すか、中身に意味のある代替表現を書いておくか。後者を勧めます。隠す方法はスクリプトが失敗したとき何も残りませんが、代替表現を書いておけば、最悪の場合でも情報は読めるままです。
使いどころとつまずきどころ
向いている場面
- 複数のサイトやフレームワークで共有したい部品
- CMSの記事本文に埋め込みたい表現
- 長期間メンテナンスするデザインシステム
- ビルド環境を持ち込めない案件
つまずきやすい点
- Shadow DOM の中には外側のCSSが届かない(それが目的でもある)
- 属性は文字列。真偽値は存在の有無で表す
- フォーム部品は formAssociated を書かないと送信されない
- 定義前に使われると空で描画される。:defined で対処する
よくある質問
いまさらWeb Componentsを学ぶ意味はありますか。
長く置くものほど価値があります。ビルドが要らず依存も増えないので、5年後に触っても同じコードが動く可能性が高い。複数のサイトやフレームワークから使い回したい部品、CMSの本文に埋め込みたい表現、ビルド環境を持ち込めない案件では、いまでも最も確実な選択肢です。
Shadow DOM は必ず使うべきですか。
いいえ。外側のデザインシステムに馴染ませたい部品なら、使わないほうが楽です。Shadow DOM の利点は「壊されない」ことですが、裏返すと「外から直せない」ことでもあります。サイト内で完結する部品なら使わず、配布する部品なら使う、という切り分けが実務的です。
属性とプロパティはどう使い分けますか。
属性はHTMLに書く初期値、プロパティはJavaScriptから読み書きする窓口です。属性は文字列しか持てないので、数値や配列はプロパティ側で変換します。真偽値は特に注意が必要で、disabled="false" と書いても属性が存在する以上は真になります。有無で表してください。
定義される前に一瞬崩れて見えます。
スクリプトの読み込みが終わるまでカスタム要素はただの未知のタグなので、中身がそのまま表示されます。:not(:defined) セレクタで visibility: hidden にしておくか、中身に意味のある代替表現を書いておくのが対処です。後者のほうが、スクリプトが失敗したときにも強い作りになります。