Cloudbaseのデザインシステムコンポーネント設計

はじめに

こんにちは、Cloudbaseでプロダクトデザイナーをしているtaka (@nukotsuka)です。

CloudbaseはクラウドセキュリティのCNAPP SaaSで、フロントエンドはNext.js + Chakra UI v2で構築しています。 今回は社内デザインシステムコンポーネントの設計についてお話しします。

このライブラリで提供しているのが CBコンポーネントです。CBButtonCBDialogCBSelect といった CB プレフィックスのついたUIコンポーネント群で、Cloudbaseのデザインシステムのコンポーネントライブラリとして機能しています。エンジニアはこれらのコンポーネントを組み合わせることで、FigmaのデザインをそのままUIとして実装できます。

このCBコンポーネントは Ark UI + Chakra UI v2 を組み合わせて実装されています。この記事では、その選定背景・具体的な実装パターン・型による設計の工夫・運用体制について紹介します。

なぜArk UI + Chakra UIを選んだか

Chakra UIの「分解」

Chakra UIの開発者であるSage Adebayoは、Chakra UIが持つ複数の責務を独立したライブラリとして分解する方針をThe future of Chakra UIの中で発表しました。

  • Zag.js — UIコンポーネントのState machine(低レベルなふるまいの定義)
  • Ark UI — Zag.jsをベースにしたHeadlessコンポーネントライブラリ(フレームワーク非依存)
  • Panda CSS — Zero-runtime CSS-in-JS

これらの関係は以下のように整理されています。

  • Zag.js: low-level state machine for UI components

  • Ark: Headless components based on Zag.js

  • Chakra: Ark + runtime CSS-in-JS

When Panda is production-ready, we'll recommend switching to Ark and Panda for new projects.

Chakra UI図解

役割の分担

この経緯を踏まえ、CBコンポーネントでは次のように役割を分担しています。

  • Ark UI — ダイアログの開閉・セレクトのキーボード操作・チェックボックスの状態管理など、UIのふるまいとアクセシビリティを担う
  • Chakra UI — テーマによるスタイリングとTypeScriptの型安全を担う

採用した理由

CloudbaseのフロントエンドではすでにChakra UI v2を採用しており、そのスタイリング資産(カラートークン・テキストスタイル・コンポーネントテーマ)を活かしながら、複雑なUIのふるまいをArk UIに委譲するアーキテクチャを選びました。

採用当時でArk UIはすでにv3がリリースされておりproduction readyな状態でした。また、将来的にChakra UI v3やPanda CSSへの移行パスも残せる構成であることも判断材料のひとつです。

実装パターン①:シンプルなコンポーネント(CBButton)

CBButton

まず、単一要素で構成されるシンプルなコンポーネントを例に実装パターンを説明します。

コンポーネント本体

import React, { createElement, forwardRef } from "react";
import {
  chakra,
  omitThemingProps,
  type HTMLChakraProps,
  useStyleConfig,
  type ThemingProps,
} from "@chakra-ui/react";
import { type HTMLArkProps, ark, type Assign } from "@ark-ui/react";
import type { LucideIcon } from "lucide-react";
import { CBSpinner } from "../../components/CBSpinner";

type CBButtonOptions = {
  isDisabled?: boolean;
  isLoading?: boolean;
  leftIcon?: LucideIcon;
  rightIcon?: LucideIcon;
};

type CBButtonVariantProps = ThemingProps<"CBButton">;

export type CBButtonProps = Assign<HTMLArkProps<"button">, HTMLChakraProps<"button">> &
  CBButtonOptions &
  CBButtonVariantProps;

const ChakraButton = chakra(ark.button);

export const CBButton = forwardRef<HTMLButtonElement, CBButtonProps>((props, ref) => {
  const styles = useStyleConfig("CBButton", props);
  const ownProps = omitThemingProps(props);
  const { isDisabled, isLoading, leftIcon, rightIcon, children, ...rest } = ownProps;

  return (
    <ChakraButton ref={ref} __css={styles} disabled={isDisabled || isLoading} {...rest}>
      {isLoading ? (
        <>
          <CBSpinner position="absolute" width="1em" height="1em" />
          <chakra.span gap="inherit" display="inherit" opacity="0">
            <>
              {leftIcon && createElement(leftIcon)}
              {children}
              {rightIcon && createElement(rightIcon)}
            </>
          </chakra.span>
        </>
      ) : (
        <>
          {leftIcon && createElement(leftIcon)}
          {children}
          {rightIcon && createElement(rightIcon)}
        </>
      )}
    </ChakraButton>
  );
});
CBButton.displayName = "CBButton";

ポイントは3つです。

chakra(ark.button) による橋渡し

chakra() はChakra UIが提供するファクトリ関数で、任意のHTML要素やコンポーネントにChakraのスタイリング能力を付与します。ark.button はArk UIが提供するアクセシビリティ属性付きの <button> 要素です。chakra(ark.button) とすることで、Ark UIのふるまいとChakra UIのスタイリングを1つの要素に統合できます。

useStyleConfig + __css でテーマを適用

useStyleConfig("CBButton", props) はChakraのテーマから CBButton コンポーネントのスタイル定義を取得します。取得したスタイルオブジェクトを __css propとして渡すことで、テーマで定義したスタイルが適用されます。

omitThemingProps で型を分離

props にはChakraのtheming props(variantsizecolorScheme)が混ざっています。omitThemingProps でこれらを除外してから残りのpropsをDOM要素に渡すことで、不要なHTML属性がDOMに渡るのを防ぎます。

テーマ定義

import { defineStyleConfig } from "@chakra-ui/react";

export default defineStyleConfig({
  baseStyle: {
    cursor: "pointer",
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    position: "relative",
    whiteSpace: "nowrap",
    outline: "none",
    _focusVisible: {
      outlineWidth: "2px",
      outlineStyle: "solid",
      outlineColor: "blue.600",
      outlineOffset: "2px",
    },
    _disabled: {
      cursor: "not-allowed",
    },
  },
  variants: {
    primary: {
      backgroundColor: "blue.600",
      color: "white",
      _hover: { backgroundColor: "blue.700" },
      _disabled: {
        opacity: 0.4,
        _hover: { backgroundColor: "blue.600" },
      },
    },
    secondary: {
      backgroundColor: "white",
      color: "gray.800",
      borderWidth: "1px",
      borderColor: "gray.300",
      _hover: { backgroundColor: "gray.100" },
    },
    tertiary: {
      backgroundColor: "transparent",
      color: "gray.800",
      _hover: { backgroundColor: "gray.100" },
    },
    danger: {
      backgroundColor: "red.600",
      color: "white",
      _hover: { backgroundColor: "red.700" },
    },
  },
  sizes: {
    sm: {
      gap: "2",
      borderRadius: "md",
      height: "8",
      paddingX: "3",
      textStyle: "xs/Medium",
    },
    md: {
      gap: "2",
      borderRadius: "md",
      height: "10",
      paddingX: "4",
      textStyle: "sm/Medium",
    },
  },
  defaultProps: {
    variant: "primary",
    size: "md",
  },
});

defineStyleConfig でコンポーネントのスタイルをコンポーネントの外に切り出して定義します。variants でボタンの種類を、sizes でサイズを管理します。

実装パターン②:マルチパーツコンポーネント(CBSelect)

CBSelect

ドロップダウンやダイアログのような、複数のDOM要素で構成されるコンポーネントには異なるパターンを使います。

Anatomyとは

Ark UIはコンポーネントをAnatomy(解剖)という概念で部品に分解します。たとえばSelectコンポーネントは次のような部品で構成されます。

  • Select.Root — 状態管理の起点
  • Select.Control — コントロール領域
  • Select.Trigger — クリックで開くトリガー
  • Select.ValueText — 選択中の値のテキスト
  • Select.Content — ドロップダウン本体
  • Select.Item — 各選択肢
  • Select.ItemIndicator — 選択済みを示すアイコン領域

それぞれのパーツに個別にスタイルを当てる必要があり、Chakra UIの createMultiStyleConfigHelpers を使って管理します。

コンポーネント本体

import React, { forwardRef, useCallback, useMemo } from "react";
import { chakra, type StyleProps, useMultiStyleConfig } from "@chakra-ui/react";
import { createListCollection, Select } from "@ark-ui/react";
import { CBTooltip } from "../CBTooltip";
import { CheckIcon, ChevronDownIcon } from "../Icon";

type Item<T extends string> = { label: string; value: T };

export type CBSelectProps<T extends string> = {
  items: Item<T>[];
  value: T | undefined;
  placeholder?: string;
  isDisabled?: boolean;
  tooltip?: string | ((isDisabled: boolean) => string | undefined);
  width?: StyleProps["width"];
  onChange: (value: T) => void;
};

const ChakraSelectControl = chakra(Select.Control);
const ChakraSelectTrigger = chakra(Select.Trigger);
const ChakraSelectValueText = chakra(Select.ValueText);
const ChakraSelectIndicator = chakra(Select.Indicator);
const ChakraSelectPositioner = chakra(Select.Positioner);
const ChakraSelectContent = chakra(Select.Content);
const ChakraSelectItem = chakra(Select.Item);
const ChakraSelectItemText = chakra(Select.ItemText);
const ChakraSelectItemIndicator = chakra(Select.ItemIndicator);

const CBSelectInner = <T extends string>(
  { items, value, placeholder, isDisabled, tooltip, width, onChange }: CBSelectProps<T>,
  ref: React.ForwardedRef<HTMLDivElement>
) => {
  const ChakraSelectRoot = useMemo(() => chakra(Select.Root<Item<T>>), []);
  const styles = useMultiStyleConfig("CBSelect");
  const collection = useMemo(() => createListCollection({ items }), [items]);

  const handleValueChange = useCallback(
    ({ items }: { items: Item<T>[] }) => {
      if (items[0] !== undefined) onChange(items[0].value);
    },
    [onChange]
  );

  return (
    <ChakraSelectRoot
      ref={ref}
      width={width}
      __css={styles.root}
      collection={collection}
      disabled={isDisabled}
      onValueChange={handleValueChange}
      value={value !== undefined ? [value] : []}
    >
      <CBTooltip label={tooltip}>
        <ChakraSelectControl __css={styles.control}>
          <ChakraSelectTrigger __css={styles.trigger}>
            <ChakraSelectValueText
              __css={styles.valueText}
              placeholder={placeholder}
            />
            <ChakraSelectIndicator __css={styles.indicator}>
              <ChevronDownIcon />
            </ChakraSelectIndicator>
          </ChakraSelectTrigger>
        </ChakraSelectControl>
      </CBTooltip>
      <ChakraSelectPositioner __css={styles.positioner}>
        <ChakraSelectContent __css={styles.content}>
          {items.map((item) => (
            <ChakraSelectItem key={String(item.value)} __css={styles.item} item={item}>
              <ChakraSelectItemText __css={styles.itemText}>{item.label}</ChakraSelectItemText>
              <ChakraSelectItemIndicator __css={styles.itemIndicator}>
                <CheckIcon />
              </ChakraSelectItemIndicator>
            </ChakraSelectItem>
          ))}
        </ChakraSelectContent>
      </ChakraSelectPositioner>
    </ChakraSelectRoot>
  );
};

export const CBSelect = forwardRef(CBSelectInner) as <T extends string>(
  props: CBSelectProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
) => React.ReactElement;

シンプルコンポーネントとの違いは2点です。

① 各Ark UIパーツを個別に chakra() でラップする

chakra(Select.Control) のように、Anatomyの各パーツをそれぞれ chakra() でラップして Chakra* という命名の変数に格納します。これにより各パーツが独立してChakraのスタイリング能力を持ちます。

useMultiStyleConfig でスロットごとのスタイルを取得する

useMultiStyleConfig("CBSelect"){ root, control, trigger, content, item, ... } のようなオブジェクトを返します。各パーツに対応するスタイルをそれぞれ __css で渡すことで、テーマで定義したスタイルがAnatomyの各部品に適用されます。

アニメーションの実装

Ark UIの data-state 属性を利用してopen/closeアニメーションを実現しています。

import { createMultiStyleConfigHelpers, keyframes } from "@chakra-ui/react";

const fadeIn = keyframes({ "0%": { opacity: 0 }, "100%": { opacity: 1 } });
const fadeOut = keyframes({ "0%": { opacity: 1 }, "100%": { opacity: 0 } });
const scaleIn = keyframes({ "0%": { scale: "0.95" }, "100%": { scale: "1" } });
const scaleOut = keyframes({ "0%": { scale: "1" }, "100%": { scale: "0.95" } });

// テーマ定義内
content: {
  "&[data-state='open']": {
    animation: `${scaleIn} .2s, ${fadeIn} .2s`,
  },
  "&[data-state='closed']": {
    animation: `${scaleOut} .1s, ${fadeOut} .1s`,
  },
},

Ark UIのコンポーネントは状態に応じて data-state="open" / data-state="closed" をDOMに付与します。これをChakraのkeyframesと組み合わせることで、close時のアニメーションが完了してからDOMから消える自然なトランジションを実現できます。

設計の原則:型でコンポーネントの使われ方を制限する

Chakra UIのstyle propsをあえて公開しない

CBButtonの型定義をもう一度見てみます。

export type CBButtonProps = Assign<HTMLArkProps<"button">, HTMLChakraProps<"button">> &
  CBButtonOptions &
  CBButtonVariantProps;

HTMLChakraProps<"button"> はChakra UIのstyle props(colorfontSizepaddingなど)を含む型です。しかし実際のコンポーネントは useStyleConfig で取得したテーマのスタイルを __css で適用し、外部から渡されたstyle propsはそのまま ...rest で流れます。

この設計により、variantsize だけでボタンの見た目をコントロールする使い方が自然になります。呼び出し側での使い方はシンプルです。

// ✅ 意図した使い方
<CBButton variant="primary" size="md">保存</CBButton>
<CBButton variant="danger" leftIcon={TrashIcon}>削除</CBButton>

// ✅ ローディング状態も props で表現
<CBButton isLoading={isMutating}>送信</CBButton>

AIによるUI実装のガードレールとして機能する

近年、AIを活用したUI実装が増えています。AIが生成するコードは時として、コンポーネントの意図を無視して style={{ color: "red" }}sx={{ fontSize: "20px" }} で直接スタイルを上書きしてしまうことがあります。

CBコンポーネントでは variant / size という明示的なAPIのみで見た目を変える設計になっているため、型定義とlint設定を通じて「想定外のスタイル上書き」を検出しやすくなります。Figmaで定義していない色・フォントサイズ・余白が使われそうになったとき、AIが生成したコードでも型エラーとして表面化します。

これは意図せずして実現した効果ですが、デザインシステムのコンポーネントとして見ると、使い方を制約することが実装の品質を保つガードレールになるという設計原則の好例だと思っています。

運用:デザイナーがFigma・実装の両方を管理する

Web Guildとは

CloudbaseにはWeb Guildという社内組織があります。チーム(ドメイン単位の縦割り)とは別に技術スタックで横断する「ギルド」のひとつで、Web UIとその周辺バックエンド開発に強いメンバーが週次の定例で同期します。CBコンポーネントは当初このWeb Guildがオーナーとしてデザイナーとコミュニケーションを取りながら管理していました。

FigmaとコードのGapをどう埋めたか

以前は、FigmaのデザインとCBコンポーネントの実装の間に乖離が生まれやすい状態でした。デザイナーが新しいバリアントを追加してもコードに反映されるまでにラグがあったり、逆にエンジニアが実装上の都合でFigmaにないスタイルを当てていたりと、「正」がどちらにあるかが曖昧な状態が続いていました。

転機となったのは、Web Guildにも所属していた私がエンジニアからデザイナーに転身し、CBコンポーネントのオーナーシップをプロダクトデザインチームに移したことです。

note.com

現在のフローはこうなっています。

  1. デザイナーがFigmaで新しいUIを設計する
  2. 既存のCBコンポーネントで実現できない場合、デザイナーがFigmaとコードの両方を変更する
  3. 変更済みのCBコンポーネントをエンジニアに渡し、それを使ってUIを実装してもらう

「デザイナーがコードも変更する」というのは一見ハードルが高く聞こえますが、Chakra UIのテーマベースのスタイリングは宣言的でわかりやすく、デザイントークン(カラー・スペーシング)の語彙がFigmaと揃っていれば変更コストは小さくなります。

この体制にしてから、FigmaとCBコンポーネント実装の差分はほぼなくなりました。

まとめ

Ark UI × Chakra UIでCBコンポーネントを実装することで、次の3つの恩恵を得られました。

  1. ふるまいの委譲 — ダイアログの開閉やセレクトのキーボード操作など複雑なインタラクションをArk UIに任せ、アクセシビリティを確保しながら実装コストを下げられた
  2. 型によるガードレールvariant / size のみで見た目を変えるAPI設計にすることで、エンジニアやAIによる意図しないスタイル上書きを防ぎやすくなった
  3. デザインと実装の一致 — デザイナーがFigmaとコードの両方にオーナーシップを持つフローにより、FigmaとCBコンポーネントの乖離がなくなった

ライブラリの選定は技術的な判断だけでなく、誰がどう管理するかという運用設計とセットで考えることが重要だと、この経験を通じて改めて感じました。

Cloudbaseでは現在、このデザインシステムをさらに育ててくれる2人目のプロダクトデザイナーを探しています。

「複雑なセキュリティ運用を、心地よい体験に。」というミッションのもと、デザインシステム・IA設計・UI設計を横断してプロダクトの品質を上げていく仕事です。

興味がある方は、ぜひこちらのJDもご覧ください。