使い方
このページを上から読めば、一通りのことはできるようになります。引きたいだけなら リファレンスへ。
はじめに
<script> 1 本で使う方法と、npm から使う方法があります。どちらでも
機能は同じです。
<script type="module" src="https://cdn.jsdelivr.net/npm/@hidemikimura/chit-ui/dist/chit-ui.min.js"></script>
<chit-ui id="chat"></chit-ui>
npm install @hidemikimura/chit-ui lit
import '@hidemikimura/chit-ui'; // <chit-ui> を登録する
import { greenTheme } from '@hidemikimura/chit-ui/themes.js'; // 付属テーマ
import type { Message, Theme } from '@hidemikimura/chit-ui'; // 型
タグ名を自分で決めたいときは、登録しないエントリを使います。
import { ChitUI } from '@hidemikimura/chit-ui/element.js';
customElements.define('my-chat', ChitUI);
単一バンドル(dist/chit-ui.min.js)は Lit を同梱しています。npm 版と
同じページで混ぜると Lit が二重に読み込まれるので、どちらか一方にしてください。
window.ChitUI を作る IIFE 版もあります。
送信を受ける
送信されると chat-submit が飛びます。発言を積むのも、バックエンドに
聞きに行くのも、あなたのコードです。ライブラリは messages を書き換えません。
chat.addEventListener('chat-submit', async (event) => {
const text = event.detail.text.trim();
if (!text) { event.preventDefault(); return; } // 中止すると入力欄も消えない
chat.messages = [...chat.messages, { id: crypto.randomUUID(), role: 'user', text }];
chat.busy = true; // 入力欄をロック
chat.loading = true; // 応答待ちの表示
const reply = await yourBackend(text);
chat.loading = false;
chat.busy = false;
chat.messages = [...chat.messages,
{ id: crypto.randomUUID(), role: 'assistant', text: reply }];
});
messages は必ず新しい配列を代入してください。
push しても Lit からは変化が見えないため、描画されません。
id は差分描画のキーなので、同じ発言には同じ id を
使い続けます。
発言の中身
発言は text / html / template /
element / component のいずれか 1 つを持ちます。
{ id: 'a', role: 'assistant', text: '承知しました。\n担当におつなぎします。' }
{ id: 'b', role: 'assistant', html: '<p><a href="/orders">注文履歴</a>をご覧ください</p>' }
{ id: 'c', role: 'assistant', template: html`<p>${name} 様</p>` }
{ id: 'd', role: 'assistant', element: myElement }
{ id: 'e', role: 'assistant', component: 'order-card', props: { orderId: '1001' } }
role は 'user' / 'assistant' /
'system'。system は中央寄せの連絡行で、名前もアイコンも
出ません。ほかに time、status
('sending' / 'sent' / 'error')、
streaming、そして自由に使える meta を持てます。
人が書いた文章は text で
text は Lit がテキストノードとして描くので、エスケープが構造的に
保証されます。改行もそのまま表示されます。利用者が入力した文章、LLM が生成した文章、
バックエンドから返ってきた文章は、まず text を選んでください。
リンクや強調が必要で、かつ HTML を自分で組み立てているときだけ html を
使います。その場合は sanitize を必ず設定してください。
import DOMPurify from 'dompurify';
chat.sanitize = (html) => DOMPurify.sanitize(html);
コンポーネントを置く
component にはカスタム要素のタグ名かコンストラクタを渡し、
props がそのままプロパティとして代入されます。同じ id で
ある限りインスタンスは再利用され、変わった props だけが再代入されます。
chat.messages = [...chat.messages, {
id: 'order-1001',
role: 'assistant',
component: 'order-card',
props: { orderId: '1001', total: 4800 },
meta: { orderId: '1001' },
}];
発言の中のボタンは、描画のたびにリスナーを付け直すより
chat-message-click で一括して受けるほうが簡単です。
target はコンポーネントの Shadow DOM の中まで辿ってあります。
chat.addEventListener('chat-message-click', (event) => {
const action = event.detail.target.closest('[data-action]')?.dataset.action;
if (action === 'detail') showOrder(event.detail.message.meta.orderId);
});
ストリーミング
同じ id の発言を書き換えていきます。streaming: true の間は
末尾にカーソルが出て、aria-busy が立つので 1 文字ずつ読み上げられません。
const id = crypto.randomUUID();
chat.messages = [...chat.messages, { id, role: 'assistant', text: '', streaming: true }];
for await (const chunk of stream) {
chat.messages = chat.messages.map((m) =>
m.id === id ? { ...m, text: m.text + chunk } : m);
}
chat.messages = chat.messages.map((m) =>
m.id === id ? { ...m, streaming: false } : m);
開く・閉じる・隠す
状態は 'closed'(ランチャーだけ)、'open'(パネル)、
'hidden'(何も出さない)の 3 つです。メソッドはアニメーションの完了で
resolve する Promise を返します。中止された場合は false です。
await chat.open();
await chat.close();
chat.hide(); // ログアウト中は消しておく、など
chat.show();
chat-before-open / chat-before-close を
preventDefault() すると遷移そのものを止められます。営業時間外は開かせない、
といった制御はここで書きます。イベントの trigger を見れば、人が押したのか
('user')コードから呼ばれたのか('api')がわかります。
テーマ
部分指定で構いません。書かなかった項目は既定値で埋まります。実行中に差し替えると 即座に反映されるので、ダークモードの切り替えにも使えます。
chat.theme = {
breakpoint: 768, // これ未満の幅をスマホとみなす
closed: {
pc: { size: 64, image: '/icon.png', offset: { x: 24, y: 24 } },
mobile: { size: 56, offset: { x: 16, y: 16 } },
},
open: {
width: 380, height: 600,
colors: { accent: '#2563eb', userBubble: '#2563eb' },
bubble: { radius: 14, tail: 'none' },
header: { title: 'サポート' },
},
};
null を渡すと既定値を消せます(image: null で既定アイコンを
外す、など)。closed を pc / mobile に
分けなければ、両方に同じ設定が使われます。スマホ幅では open は常に
全画面で、width と height は無視されます。
付属テーマ
見た目を一から決めたくないときは、同梱のプリセットをそのまま渡せます。
import { greenTheme } from '@hidemikimura/chit-ui/themes.js';
chat.theme = greenTheme;
chat.theme = { ...greenTheme, open: { ...greenTheme.open, width: 420 } }; // 重ねる
greenTheme は淡いブルーグレーの会話背景に、相手は白・自分は黄緑の吹き出し、
しっぽは上向き、という配色です。特定のサービスのロゴや素材は含みません。読む必要のある
文字と背景の組み合わせはすべて WCAG AA(4.5:1)を満たしていて、テストが毎回検証します。
CSS から触る
テーマの値は CSS カスタムプロパティになります。ページ側の指定のほうが強いので、 色をひとつ変えたいだけなら CSS のほうが早いです。
chit-ui {
--chit-color-accent: #d81b60;
--chit-launcher-size: 72px;
}
chit-ui::part(bubble) { font-size: 15px; }
chit-ui::part(header) { border-bottom: none; }
使える変数とパーツ名はリファレンスにあります。
--_chit- で始まる変数は内部用なので触らないでください。
タイトルバー
chat.theme = { open: { header: {
visible: true, // false にするとタイトルバーごと消える
title: 'サポート',
logo: '/logo.png',
home: true, // ホームボタンを出す
} } };
ホームボタンは押しても何も消しません。chat-home を出すだけです。
どこまで巻き戻すか、確認を挟むかは、発言を持っているあなたが決めます。
chat.addEventListener('chat-home', (event) => {
if (event.detail.trigger === 'user' && !confirm('最初からやり直しますか?')) return;
chat.messages = [firstMessage()];
});
visible: false は、LINE の LIFF のように外側のアプリが既にタイトルを
持っている画面向けです。タイトルが二重に並ぶのを避けられます。閉じるボタンも一緒に
消えますが、Esc とランチャーでは閉じられます。
待っている間
相手が機械のとき「入力中」は実態と違うので、スピナーや文言を出す
loading を別に用意しています。typing とは排他で、
一方を true にすると他方は false になります。
chat.theme = { open: { loading: {
auto: true, // 送信から相手の発言が届くまで自動
style: 'spinner', // 'dots' / 'spinner' / 'text'
text: 'お調べしています',
timeout: 30000, // ms。過ぎたら引っ込む
} } };
auto を使わないなら chat.loading = true を自分で上げ下げ
します。人が相手の有人チャットなら chat.typing = true のほうが素直です。
入力欄
chat.theme = { open: { input: {
placeholder: 'ご質問をどうぞ',
maxRows: 5, // これ以上は伸びずにスクロール
attach: true, // 画像・動画の添付ボタンを出す
accept: 'image/*,video/*',
multiple: false,
} } };
添付ボタンはファイルが選ばれると chat-attach
(detail.files は File[])を出して手を離します。
アップロードもプレビューも発言の追加もしません。
chat.inputDisabled = true は見せたままロック、
chat.inputHidden = true は入力欄ごと消します。消しても
submit(text) は使えるので、選択肢のボタンだけで進むシナリオ型の会話が
作れます。
発言者の名前とアイコン
chat.theme = { open: { speaker: {
assistant: { name: '担当', avatar: '/support.png' },
user: { name: null, avatar: null },
} } };
発言側の name / avatar のほうが優先されます。発言に
null を書けばテーマの指定を打ち消せるので、「同じ人が続けて話すときは
2 件目以降のアイコンを省く」といった見せ方も作れます。連続かどうかの判定は
ライブラリではしません。messages を持っているのはあなただからです。
吹き出しのしっぽ
chat.theme = { open: { bubble: { radius: 18, tail: 'top' } } };
tail は 'none'(既定)/ 'top' /
'bottom'。角丸をいくつにしても継ぎ目に隙間が出ないように、しっぽの
根元を吹き出しの内側に潜り込ませてあります。clip-path: path() に
対応していないブラウザではしっぽが出ず、ふつうの吹き出しとして表示されます。
閉じた状態を自分で描く
3 通りあります。画像やラベルを渡す、スロットに中身を入れる、丸ごと差し替える。
chat.theme = { closed: { image: '/icon.png', label: 'お問い合わせ' } };
<chit-ui>
<span slot="launcher" class="badge">3</span>
</chit-ui>
chat.theme = { closed: { component: 'pill-launcher', props: { text: '相談する' } } };
component を渡したときは、外側のボタンが幅・高さ・背景・影・角丸・余白を
手放します。ひとつの箱を両方からスタイルすると、はみ出しや切り取りが起きるためです。
ボタンであること(読み上げ名、開閉状態、キーボード操作)は残ります。
ドラッグで動かす
chat.theme = { closed: { draggable: true }, open: { draggable: true } };
閉じた状態はランチャーそのもの、開いた状態はタイトルバーが取っ手です。矢印キーでも 動かせます(Shift で大きく)。画面の外には出られません。
動かしているのは位置そのものではなく「テーマの位置からのずれ」で、閉じた状態と開いた 状態がそれをひとつだけ共有します。ランチャーを動かしてから開けばパネルも同じだけずれ、 その逆も同じです。2 つは同じウィジェットの別の姿なので、片方だけ元の場所に残るほうが 不自然だからです。
chat.addEventListener('chat-move', (event) => {
localStorage.setItem('chit-offset', JSON.stringify(event.detail.displacement));
});
const saved = localStorage.getItem('chit-offset');
if (saved) chat.dragOffset = JSON.parse(saved); // 復元
chat.resetPosition(); // テーマの位置に戻す
スクロールの追従
新しい発言が届いたとき、最下部にいる読み手の画面はアニメーションで追従します。 さかのぼって読んでいる人は動かされず、「最新へ」のボタンが出ます。自分が送った発言は どこにいても表示されます。押して送ったばかりなのだから、見えていてほしいからです。
chat.theme = { open: { animation: { scroll: 'instant' } } }; // 追従を即時に
ひとつの発言が伸びていく場合(ストリーミング、画像の読み込み)は設定によらず即時です。
prefers-reduced-motion が有効な環境でも即時になります。
一覧が最上部に達すると chat-scroll-top が 1 回だけ出ます。古い発言の
追い読みはここで。
フォーカスでページが飛びません
パネルが開くと、入力欄(無ければパネル自身)にフォーカスが移ります。このとき
ページのスクロール位置は動きません。ライブラリが出すフォーカスはすべて
preventScroll つきで、それを無視するブラウザ向けに、ずれていたら
元の位置へ戻します。
ウィジェットは position: fixed なので本来は動きようがないのですが、
祖先に transform や filter、will-change が
ひとつでもあると fixed はその箱を基準に測られます。するとウィジェットは
文書のずっと下に置かれることになり、フォーカスが移った瞬間にブラウザがそこまで
ページをスクロールしてしまいます。SPA のページ遷移用のラッパーや、アニメーション用の
箱でよく起きます。iframe の中でも同じです。
自動フォーカスそのものが要らなければ止められます。開いたときに読み手の入力を 横取りしたくない場合にどうぞ。
chat.focusOnOpen = 'never'; // 'auto'(既定・PC のみ)/ 'always' / 'never'
スマホでの振る舞い
幅が breakpoint(既定 768)未満のときはパネルが全画面になります。
そのうえで、スマホでよく壊れるところを先に潰してあります。
- 入力欄の文字は 16px を下回りません。iOS Safari はそれより小さい欄にフォーカスすると ページを拡大し、離れても戻さないためです。ウィジェット全体の文字サイズは変わりません。
-
キーボードが出ている間、パネルは見えている範囲(
visualViewport)に 収まります。入力欄と直前の発言がキーボードの下に隠れません。 - パネルの上を指でなぞっても、後ろのページはスクロールしません。会話が短くて スクロールするものが無いときも同じです。
- ピンチでの拡大には手を出しません。拡大は読み手のものだからです。
アクセシビリティ
-
ランチャーは
button、パネルはrole="dialog"、 発言一覧はrole="log"+aria-live="polite" - Esc で閉じ、開閉に合わせてフォーカスがランチャーとパネルの間を行き来します
- ストリーミング中の発言は
aria-busy="true"です prefers-reduced-motionを尊重します- 既定テーマと付属テーマの配色は、すべて WCAG AA(4.5:1)以上です
対応ブラウザ
Chrome / Edge / Firefox / Safari の最新 2 バージョンと iOS Safari 16 以降。 IE は非対応です。
AI に書かせるとき
パッケージに skills/chit-ui/ が入っています。Claude Code などの
コーディング支援に読ませると、messages の所有者が利用者側であることや、
push では描画されないことといった、このライブラリの癖を踏まえた
コードになります。
mkdir -p .claude/skills
ln -s ../../node_modules/@hidemikimura/chit-ui/skills/chit-ui .claude/skills/chit-ui