0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Tailwind CSSで組むグラスモフィズムのデザインシステム——1つのカードに集約し、明るい壁紙と1行の高さにも耐えさせる

0
Posted at

AppleのmacOSやiOS、Windows 11のFluent Designに見られる「すりガラス(Glassmorphism)」のUIは、美しく未来的で、背景の壁紙の色彩を活かしながらコンテンツの視認性を高める優れたデザイン手法だ。

自作の新しいタブダッシュボード「ZenithTab」でも、デザインシステムの中心にこのグラスモフィズムを据えた。

image.png

Tailwind CSSを使えば、グラスモフィズムの実装自体は驚くほど簡単だ。

<div class="bg-slate-900/40 border border-white/10 backdrop-blur-md shadow-xl rounded-2xl">
  ...
</div>

わずかこれだけのクラス指定で、透明感とぼかし効果が手に入る。

問題は「1つ作ること」ではなく、20個近いカードが同時に並び、壁紙がユーザー次第で真っ白にも真っ黒にもなり、カードの高さが1行(90px)まで縮められるダッシュボードで、この見た目を破綻させずに維持し続けることだ。

ZenithTab で採った設計判断——すりガラスの品質を1つのコンポーネントに集約し、GPU コストを理解した上でユーザーに調整を委ね、明るい壁紙と小さなカードに CSS だけで適応させる——を記録する。


backdrop-filter のコストを理解しておく

通常の opacity(不透明度)の合成であれば、GPUは単に前面のピクセルと背後のピクセルをアルファブレンドするだけで済む。

しかし、backdrop-filter: blur(...) の処理負荷はそれとは次元が違う。

  1. 背後バッファの読み出し: ブラウザのレンダリングエンジンは、まずカードの背後にあるすべての描画結果(壁紙、背後のテキスト、アニメーション要素)をテクスチャとして読み出す。
  2. 2パスのガウスぼかし(Gaussian Blur)演算: 読み出したテクスチャに対して、GPUシェーダーが水平方向と垂直方向の2パスに分けて畳み込み演算(ぼかし処理)を実行する。半径16pxのぼかしであれば、1ピクセルごとに周囲数十ピクセルのサンプリングと重み付け加算が走る。
  3. 前面コンテンツとの再合成: ぼかした結果の上に、カード自身の半透明背景色、枠線、そしてカード内部のテキストやアイコンを描画してブレンドする。

これが画面上に1個だけであれば、近年のGPUにとって大した負荷ではない。

だが、ダッシュボード画面には時計、天気、カレンダー、メモ、タスク、RSSなど、10個から20個ものグラスカードが同時に密集する。ウィジェットをドラッグして位置を変えれば、カードの背後のピクセルが毎フレーム変わり、ぼかしは毎フレーム再計算される。

このコストは「ゼロにする」ことはできない。できるのは、どこで払うかを1か所に集め、払う量をユーザーが選べるようにすることだ。


施策1:コンポーネントによる「すりガラス品質」の一元化

最初に決めたのは、グラスモフィズムのクラスを個々のウィジェットに書かせず、src/components/common/GlassCard.tsx という単一の再利用可能なコンポーネントに集約することだった。すべてのウィジェットはこのカードの中に描画される。

export interface GlassCardProps extends React.HTMLAttributes<HTMLDivElement> {
  children: React.ReactNode;
  variant?: 'solid' | 'translucent' | 'transparent';
  hoverEffect?: boolean;
}

export const GlassCard = React.forwardRef<HTMLDivElement, GlassCardProps>(
  ({ className, children, variant = 'translucent', hoverEffect = false, style, ...props }, ref) => {
    // すべてのウィジェットがこのカードに包まれるので、読む2フィールドだけを購読する
    const borderRadius = useDashboardStore((s) => s.appearance.borderRadius);
    const glassBlur = useDashboardStore((s) => s.appearance.glassBlur);

    const radiusClasses = {
      none: 'rounded-none', sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg',
      xl: 'rounded-xl', '2xl': 'rounded-2xl', full: 'rounded-3xl',
    }[borderRadius || '2xl'];

    const variantStyles = {
      solid: 'bg-slate-900/80 border-white/10',
      translucent: 'bg-glass border-white/10 shadow-xl shadow-black/20',
      transparent: 'bg-transparent border-transparent',
    }[variant];

    return (
      <div
        ref={ref}
        className={cn(
          'border backdrop-blur-md transition-all duration-200',
          radiusClasses,
          variantStyles,
          hoverEffect && 'hover:bg-slate-900/50 hover:border-white/20 hover:shadow-2xl',
          className
        )}
        style={{
          // ぼかし量はユーザー設定なので、Tailwind の固定クラスではなくインラインで当てる
          backdropFilter: variant !== 'transparent' ? `blur(${glassBlur}px)` : undefined,
          ...style,
        }}
        {...props}
      >
        {children}
      </div>
    );
  }
);

ぼかし量をインラインスタイルにしているのには理由がある。Tailwind は blur-[${n}px] のような動的クラスをビルド時に生成できない(使われるクラス名を静的に走査するため)。ユーザーが設定で選ぶ値のように実行時にしか決まらないものは、インラインスタイルで当てるしかない。この方針はプロジェクトの開発ルールにも明記してある。

角丸(borderRadius)も同じくユーザー設定で、こちらは有限の選択肢なので Tailwind のクラス名を辞書引きしている。

この一元化により、個別のウィジェットが好き勝手に backdrop-blur-xl や blur-sm を散らす状態を防ぎ、アプリケーション全体のぼかし量を中央からコントロールできる体制を整えた。モーダルやドックなど、ウィジェット以外の一部の要素は独自に backdrop-blur を持つが、それらは同時に1〜2個しか表示されない。


施策2:ユーザーに委ねる「ぼかし量(Blur)」スライダー

どんなに最適化しても、統合 GPU の古いオフィスPCや低電力Chromebookでは、多数のブラー処理が荷が重い場合がある。ぼかし半径が大きいほどサンプリング範囲が広がり、コストは上がる。

ZenithTabでは、設定画面の「外観(Appearance)」タブに、ぼかし強度を 0px 〜 32px の範囲でスライダー調整できる設定項目を設けた。

image.png

<input
  type="range"
  min="0"
  max="32"
  value={appearance.glassBlur}
  onChange={(e) => updateAppearance({ glassBlur: parseInt(e.target.value) || 0 })}
/>
  • 標準(16px): バランスの取れたすりガラス質感。初期値。
  • 強め(24px〜32px): 深いぼかし。GPU に余裕のある環境向け。
  • 軽量(0px): ぼかし演算をほぼ行わず、半透明の単色背景に近づける。

スライダーは appearance.glassBlur をストアに書き、GlassCard が前述のセレクターで購読しているので、動かした瞬間に全カードへ反映される。

正直に付け加えると、0px にしても backdrop-filter: blur(0px) というプロパティ自体は残るため、ブラウザは合成レイヤーを作り続ける。ぼかしのサンプリングコストは消えるが、完全に「ただの div」になるわけではない。0 のときはプロパティごと外す——というのが次の小さな改善候補だ。


施策3:明るい壁紙に CSS 変数で適応する

グラスモフィズムを組む際、多くの開発者がダークな壁紙のことしか考えずに border-white/10 や bg-slate-900/40 を固定値で書いてしまう。

ZenithTab のカードも基本はダーク前提だ。だが壁紙はユーザーが自由に選び、雪景色や白いオフィスの写真が設定されることもある。そうなると、薄いスレート色のガラスの上に乗った白文字は背景に溶け、ガラス自体も「薄汚れた灰色の板」にしか見えない。

ここで採ったのは「ライトテーマを別に作る」ことではなく、壁紙の輝度に応じて CSS 変数を切り替えることだった。壁紙を 16×16 に縮小して WCAG 相対輝度を測り、明るければ <html> に data-backdrop="light" を立てる(詳細は別記事「 画像の明度をCanvasで解析してWCAG相対輝度を計算!どんな壁紙でも文字が読める「動的テキスト反転」の実装 」)。ガラスの濃さと、壁紙に直接乗る文字の色は、その属性の有無だけで決まる。

:root {
  --on-wallpaper: 255 255 255;        /* 壁紙の上に直接乗る文字: white */
  --glass-alpha: 0.4;                 /* ガラスの濃さ */
}

:root[data-backdrop="light"] {
  --on-wallpaper: 15 23 42;           /* slate-900 */
  --glass-alpha: 0.7;                 /* 明るい壁紙では座布団を濃くして、中の白文字を守る */
}

/* GlassCard の translucent variant が使うクラス */
.bg-glass {
  background-color: rgb(15 23 42 / var(--glass-alpha));
}

カードの中の文字は白のまま変えない。代わりにガラスを 0.4 から 0.7 へ濃くして、白文字のコントラストを確保する。壁紙の上に直接乗るヘッダーやページ切り替えタブだけが --on-wallpaper で濃紺に反転する。「文字を変えるか、座布団を変えるか」を要素の位置で使い分けるのがポイントだ。

変数は Tailwind の rgb(15 23 42 / var(--glass-alpha)) という形で書いているので、Tailwind のユーティリティと同じ記法で使える。React 側のコンポーネントは判定結果を一切知らない。


施策4:1行の高さまで縮んでも崩れない(コンテナクエリ)

v1.9.0 でウィジェットの最小サイズを見直し、時計や天気、カウントダウンを 横2列・縦1行(90px) まで縮められるようにした(Issue #32)。すりガラスのカードが美しくても、中身が1行に収まらずはみ出しては台無しだ。

ここで使ったのが CSS コンテナクエリだ。カードを container-type: size にしておくと、カード自身の高さに応じてスタイルを切り替えられる。ビューポートではなくカードの寸法で分岐するので、同じウィジェットが大きい枠と小さい枠で別の顔を持てる。

.widget-card {
  container-type: size;
}

@container (max-height: 110px) {
  .widget-card-header { padding-top: 0.25rem; padding-bottom: 0.25rem; }
  .widget-card-body { padding: 0.375rem 0.75rem; }
  .hide-when-short { display: none !important; }        /* 日付や補足行を隠す */
  .clock-time { font-size: 1.5rem !important; line-height: 1.1 !important; }
  .countdown-hero { flex-direction: row !important; gap: 0.5rem !important; }
}

時計ウィジェットは1行になると日付を隠して時刻だけを小さめに出し、カウントダウンは縦積みだった「数字・ラベル・名前」を横1列に並べ直す。JavaScript でサイズを測って state を切り替えるのではなく、CSS だけで完結するので、リサイズ中も再レンダリングが走らない。

もう1つの細部として、保存済みレイアウトには「追加した当時の minW / minH」が焼き込まれている。定義を下げても既存ユーザーには効かないので、グリッドに渡す直前にレジストリの現在値で上書きする applyRegistryMinimums を挟んでいる。保存データの形式は変えず(スキーマ版据え置き)、表示時に読み替えるだけだ。


まだやっていないこと

この記事で「やった」と書けるのはここまでだ。GPU コストの観点で効果が見込めるが、ZenithTab にはまだ入っていない施策も正直に挙げておく。

  • ドラッグ中・リサイズ中の一時的なブラー無効化: react-grid-layout の onDragStart / onDragStop でグリッドにクラスを付け、その間だけ backdrop-filter: none と濃いめの単色背景に切り替える手法。ドラッグ中はユーザーの目が配置に向いているので、ぼかしが消えても気づかれにくい。現状は onDragStart を Undo の記録にしか使っていない。
  • glassBlur === 0 のときに backdrop-filter プロパティ自体を外す: 上述のとおり。
  • ライトテーマ: appearance.theme に 'light' の型定義はあるが、対応する配色はまだ無い。明るい壁紙への適応(施策3)で実用上の困りごとはほぼ解消しているので、優先度は低い。

「グラスモフィズムは重い」は事実だが、ダッシュボードが実際に重くなるかは、ぼかしを どこで・いくつ・どの半径で 払っているかで決まる。それを1つのコンポーネントと1本のスライダーで制御できる状態にしておけば、後から上のような最適化を入れるのも1か所で済む。


美しさと軽さは両立できる

「リッチな視覚効果を追及すると、アプリは重くなる」

これはWebフロントエンドにおける半ば諦めに近い常識だ。しかし、GPUのレンダリングパイプラインを理解した上で、コストを払う場所を1か所に集め、量をユーザーに委ね、環境の変化には CSS 変数とコンテナクエリで適応させれば、その2つは共存できる。

  • すりガラスのスタイルを単一のコンポーネントに集約して制御する
  • ぼかし量はユーザーに開放し、動的な値はインラインスタイルで当てる
  • 壁紙の明るさやカードの大きさには、React ではなく CSS(変数・コンテナクエリ)で適応する

この設計を取り入れることで、ZenithTabは「見た目の統一感」と「環境に応じた調整余地」を両立したダッシュボードになっている。


さいごに:ZenithTabについて

本記事で紹介した設計やトラブルシューティングの知見は、すべて自作の新しいタブChrome拡張機能「ZenithTab(ゼニスタブ)」の開発を通して得られたものです。

ZenithTabは、「ブラウザを開くたびに心地よく、作業に集中できる」をコンセプトにした、完全ローカル完結・プライバシー重視のダッシュボード拡張機能です。

  • 自由なグリッド配置: 時計、天気、カレンダー、RSSリーダー、集中タイマー、習慣トラッカー、メモなど、多彩なウィジェットをグリッド上で自由に配置
  • 安心のローカル完結: 外部サーバーへのデータ送信は一切行わず、すべての設定やメモはブラウザ内に安全に保存
  • 細部へのこだわり: ガラスモーフィズム(すりガラス調UI)、ダイナミック壁紙、軽快なキーボードショートカット、そして万が一の誤操作を防ぐ「元に戻す(Ctrl+Z)」や自己修復機能を完備

Chromeウェブストアで無料公開しています。日々の作業効率化や、技術的なUI/UXの触感のお試しとして、ぜひ気軽に使ってみてください!

ソースコードはGitHubで公開しています。「面白い」「役に立った」と思っていただけたら、GitHubのスター(⭐️) や記事への いいね / ストック をいただけると、開発の大きな励みになります!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?