Button

components.css のクラスベース。<button> にも<a> にも載る(me / studio)。既定は塗り、--outline /--ghost / --size-icon / --size-sm の修飾子を持つ。

使う
フォーム送信や投稿など、画面内で 1 つだけの主操作は塗り。並ぶ副操作は --outline、取り消しは --ghost。
使わない
ナビゲーションの遷移先(リンクは下線かテキストのまま)。塗りボタンを 1 画面に 2 つ以上並べない。
配慮
高さ 2.25rem はタップ領域の下限。disabled は pointer-events: none なので、理由はボタンの外に書く。
種類
サイズ: --size-sm / 既定 / --size-icon
<a> に載せたもの / disabled
Open
src/pages/index.astro
{/* classes: @kkhys/styles/components.css */}
<button class="btn">Post</button>
<button class="btn btn--outline">Sync</button>
<button class="btn btn--ghost">Cancel</button>
<button class="btn btn--ghost btn--size-sm">Reply</button>

Spinner

無限スクロールの読み込み表示(memo / lgtm)。色は --c-sub を参照する。

使う
次ページの取得中だけ。最低 500ms は表示してチラつきを防ぐ。
使わない
初回描画やボタン内。終端と失敗はスピナーではなくテキストで告げる。
src/components/feed.astro
import Spinner from "@kkhys/ui/spinner.astro";

<Spinner />

BudouX

スロットの日本語を文節境界で <wbr> 区切りにする(me / trends / design)。.budoux クラス(word-break: keep-all)と組で使う。 幅を動かすと、右側だけが文節で折り返す。

使う
見出し・リード・カードのタイトルなど、折り返し位置が目立つ短い日本語。
使わない
長文本文(me は rehype で自動適用)、<pre> / <code>、ボタンのラベル。
Budoux なし

あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。

Budoux あり

あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。

src/pages/index.astro
import Budoux from "@kkhys/ui/budoux.astro";

<h1 class="budoux"><Budoux>タイトル</Budoux></h1>

OG メタデータ付きの外部リンクカード(me / memo)。メタデータの取得は各アプリの データ層が行い、このコンポーネントは表示だけを担う。og:image があれば blur-up 付きで横型(モバイルは縦積み)になる。

使う
本文中で単独の段落として置く外部参照。メタデータはビルド時に取得済みのものを渡す。
使わない
文中のインラインリンク、同一サイト内の相互リンクの羅列。
配慮
favicon は取得失敗時に globe へ、og:image は取得失敗時にプレースホルダなしへ自動で落ちる。
og:image と favicon あり(external)
og:image なし、favicon なし(globe にフォールバック)
タイトルのみ(内部リンク)
src/components/link-card.astro
import LinkCard from "@kkhys/ui/link-card.astro";

<LinkCard
  href={url}
  title={title}
  description={description}
  imageSrc={image?.src}
  favicon={favicon}
  external
/>

小さなサイトの中央寄せヘッダー(memo / lgtm / art / trends)。hrefでブランドリンクに、showBack で左端に戻るボタンが付く。actionsスロットは右端に操作を置く(memo の検索ボタン)。

使う
ページ数の少ないサイトのトップ chrome。下層ページは showBack でホームへ戻る導線を出す。
使わない
複数の主要ナビを持つサイト(me は独自の site-nav)。
src/layouts/layout.astro
import SiteHeader from "@kkhys/ui/site-header.astro";

<SiteHeader title="LGTM" href="/" />
<SiteHeader title="Thread" showBack />
<SiteHeader title="Thread" showBack>
  <button slot="actions" type="button">検索</button>
</SiteHeader>

中央寄せの © フッター(memo / lgtm)。links で法務リンク列、authorUrl で名前がリンクになる。

使う
本文カラム末尾の © 表記。法務ページがあれば links に並べる。
使わない
サイトマップ代わりのリンク集。
©2026 Keisuke Hayashi
src/layouts/layout.astro
import SiteFooter from "@kkhys/ui/site-footer.astro";

<SiteFooter
  author="Keisuke Hayashi"
  authorUrl="https://kkhys.me"
  links={[{ href: "/privacy", label: "Privacy Policy" }]}
/>

BlurImage

blur-up 付きの <Picture>(memo / diary / art)。20px のプレースホルダをblur(36px) で敷き、読み込み(または失敗)で 250ms かけて解く。アスペクト比を先に確保するので レイアウトが動かない。

使う
コンテンツ画像全般。radius / framed で枠、transitionName で一覧と詳細をつなぐ。
使わない
アイコンやアバターなど小さな画像、CSS 背景。
配慮
最初の 1 枚は priority(eager + fetchpriority high)。JS 無効時は BlurLoadNoscript がぼかしを解く。
グラデーションのサンプル画像
src/components/photo.astro
import BlurImage from "@kkhys/ui/blur-image.astro";
import photo from "#/assets/photo.jpg";

<BlurImage
  src={photo}
  alt="..."
  widths={[640, 1280]}
  sizes="(min-width: 640px) 640px, 100vw"
  radius="0.5rem"
  framed
  priority
/>

SearchDialog

Pagefind 検索のコマンドパレット(me / memo)。シェル・キーボード操作・読み込みは @kkhys/searchが持ち、アプリは結果行のテンプレートと描画関数だけを書く。このサイト自身の 8 ページを索引にしてあるので 実際に検索できる。結果行はヒットした h2 の節を名指しし、その見出しへ直接飛ぶ。

使う
ページ数が多く、目次だけでは辿れないサイト。トリガーは commandfor のボタンか ⌘K / Ctrl+K(単キーのショートカットは持たない)。
使わない
数ページのサイト、フィルタ 1 つで済む一覧。
配慮
IME 変換中は検索しない、Esc は入力を保持したまま閉じる、状態行は role=status のまま残す。
⌘K / Ctrl+K

    src/features/search/components/search-dialog.astro
    import SearchDialog from "@kkhys/search/dialog.astro";
    
    <SearchDialog id="search-dialog" label="記事を検索" placeholder="記事を検索">
      <div slot="filters">...</div>
      <Fragment slot="template">
        <template class="search-result-template">...</template>
      </Fragment>
    </SearchDialog>
    
    <script>
      import { mountSearchDialog } from "@kkhys/search/dialog-setup";
      mountSearchDialog("search-dialog", { bundlePath: "/pagefind/", noun: "記事", renderResult, ... });
    </script>

    InfiniteScroll

    次ページを取得してコンテナへ追記する(memo / lgtm)。トリガーが見えてから 200px 手前で読み込み、 スピナーは最低 500ms 表示、終端と失敗はテキスト。2 ページ目以降への直接アクセスはPaginatedGuard が先頭へ戻す。下のフィードは 24 件を 6 件ずつ 4 ページに分けた固定データで、/preview/infinite-scroll/2 以降を実際に fetch している。

    使う
    時系列で流し読みするフィード。ページ境界に意味がなく、途中の位置を人に伝える必要がないもの。
    使わない
    検索結果や一覧表など位置を指して参照するもの、フッターへ辿り着く必要があるページ。
    配慮
    読み込み中は aria-busy、終端は role=status、失敗は role=alert で告げる。スクロール以外の再試行手段はないので、失敗文言には再読み込みの含みを持たせる。
    1. #1

      朝の光が机の端まで届く頃、ようやくコーヒーが冷めた。

    2. #2

      駅前の書店が改装していて、児童書の棚が入口に移っていた。

    3. #3

      傘を忘れた日に限って、帰り道だけ雨が降る。

    4. #4

      古い写真の裏に、鉛筆で日付が書いてあった。

    5. #5

      夜の公園でラジオの音が遠くから聞こえてくる。

    6. #6

      雑貨屋で買った青いマグカップは、思ったより重かった。

    src/components/paginated-feed.astro
    import InfiniteScroll from "@kkhys/ui/infinite-scroll.astro";
    import PaginatedGuard from "@kkhys/ui/paginated-guard.astro";
    
    {page.currentPage > 1 && <PaginatedGuard basePath="/" />}
    <div id="feed" data-current-page={page.currentPage} data-total-pages={page.lastPage}>
      ...
    </div>
    {page.currentPage < page.lastPage && (
      <InfiniteScroll containerId="feed" errorMessage="Failed to load more posts" />
    )}

    Focus ring

    キーボードフォーカス(Tab)で全サイト共通のリングが出る。outline ベースなので角丸に追従し、レイアウトを揺らさず、Forced Colors でも消えない。

    リンク
    packages/styles/base.css
    :focus-visible {
      outline: var(--ring-width) solid var(--ring);
      outline-offset: var(--ring-offset);
    }

    Scrollbar

    ページ・要素とも 8px の細いつまみ(--c-scrollbar)。トラックは透明。

    縦

    あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。

    横

    あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。あらゆる現実をすべて自分のほうへねじ曲げたのだと思われるかもしれない。

    packages/styles/base.css
    ::-webkit-scrollbar {
      width: 8px;
      height: 8px;
    }
    
    ::-webkit-scrollbar-thumb {
      background: var(--c-scrollbar);
      border-radius: 4px;
    }