使い方

このページを上から読めば、一通りのことはできるようになります。引きたいだけなら リファレンスへ。

はじめに

<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