🧘‍♂️

shadcn/uiでもChakra UIみたいなStackコンポーネントが使いたい

2024/02/17に公開

こんにちは Spiral.AI株式会社というスタートアップでアプリケーションエンジニアをしている@hndrです。普段はフロントエンド/デザイン周りのエンジニアをしています。

Flutterの記事は年1ぐらいでZennにあげていたのですが、本業のフロントエンドの記事は初めてなのでちょっと緊張しております。

はじめに

shadcn/uiはスタイルのついていないHeadlessなUIライブラリであるRadix PrimitivesにTailwind CSSでスタイリングしたReactコンポーネントのテンプレート集です。[1]
基本的にスタイリングはTailwind CSSのutilityClassを使ってclassNameに書き連ねていく形になります。

Spiral.AIのフロントエンドでは元々Chakra UIやMUIなどCSS-in-JS系の技術を使ったUIコンポーネントライブラリを利用しており[2]、Tailwind CSSとは書き味が異なるため別プロダクトのコンポーネントをコンバートしやすくする上でもStyled System(style props)的にコンポーネントのpropsにstyleを渡せるようにしたかったというのが経緯となります。[3]

Stackコンポーネントを自作する

Stackは要素を縦または横に配置し、間にスペースを適用するために使用されるコンテナコンポーネントで、Chakra UIやMUI、Swift UIなどに同様のコンポーネントがあります。

https://chakra-ui.com/docs/components/stack
https://mui.com/material-ui/react-stack/
https://developer.apple.com/documentation/swiftui/building-layouts-with-stack-views

shadcn/uiやRadix UIにはこのコンポーネントが存在しないため、当初PandaCSSのStyle propsの利用を考えていましたが、Tailwind CSSのclassName指定と競合しそうであったため独自実装することにしました。

以下、現在実装中のプロダクトで実際に利用しながら適宜修正を加えているコンポーネントの紹介です。

RadixのSlotコンポーネントを利用して as propsでタグの種類を変更可能にする

https://www.radix-ui.com/primitives/docs/utilities/slot

Radix PrimitivesのSlotコンポーネントを利用すると、下記のようにpropsを渡すことでChakra UIなどと同様に as propsでタグを変更することが可能になります。

export const Stack = forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLElement> & StackProps
>(
  (
    {
      children,
      className,
      asChild = false,
      as: Tag = "div",
      ...props
    },
    forwardedRef
  ) => {
    return (
      <Slot
        ref={forwardedRef}
        className={className}
        {...props}
      >
        {asChild ? children : <Tag>{children}</Tag>}
      </Slot>
    );
  }
);

asChild が渡された場合は、asで指定されたtagは利用されずchildrenのComponentにpropsが継承されます。

propsに渡されたStyleをTailwind Variantsを利用しTailwindのutilityClassに変換する

shadcn/uiのコンポーネントでも利用されているClass-Variance-Authorityのみでも同様のことはできますが、型の取り回しなどのしやすさなどからTailwind Variantsを利用しました。
https://www.tailwind-variants.org/

下記のようにpropsで渡された値を、定義した stackVariantsに渡して変換し、cn関数[4]でmergeする形です。
こうすることでTailwind CSSのutilityClassとの重複などが避けられます。
※解説上variantsの定義は絞っています。

tailwindの flexbox 系のutilityClassを利用してvariantを定義していますが、工数や後述の留意事項のため完全互換は目指さず、あくまでChakra UIのStack的な使い方が大体できればよいというところで割り切っています。
また並び方向が固定の HStackVStackなども direction を固定する形で同様に作成しました。

import { Slot } from "@radix-ui/react-slot";
import React, { forwardRef } from "react";
import { tv } from "tailwind-variants";

import { cn } from "@/lib/utils";

import {
  spaceX,
  spaceY,
} from "./variants/";

import type { StackProps } from "./types";
const stackVariants = tv({
  base: "flex",
  variants: {
    spacingX: spaceX,
    spacingY: spaceY,
    justify:{
      start: "justify-start",
      end: "justify-end",
      center: "justify-center",
      between: "justify-between",
      around: "justify-around",
      evenly: "justify-evenly"
    },
    align:{
      start: "items-start",
      end: "items-end",
      center: "items-center",
      baseline: "items-baseline",
      stretch: "items-stretch"
    },
    direction: {
      horizontal: "flex-row",
      vertical: "flex-col",
      horizontalReverse: "flex-row-reverse",
      verticalReverse: "flex-col-reverse"
    },
    wrap: {
      wrap: "flex-wrap",
      nowrap: "flex-nowrap",
      reverse: "flex-wrap-reverse"
    }
  },
  defaultVariants: {
    justify: "start",
    align: "start",
    direction: "horizontal",
    wrap: "nowrap"
  }
});

export const Stack = forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLElement> & StackProps
>(
  (
    {
      children,
      className,
      asChild = false,
      as: Tag = "div",
      direction = "horizontal",
      spacing = 2,
      spacingX = direction === "horizontal" ? spacing : undefined,
      spacingY = direction === "vertical" ? spacing : undefined,
      justify,
      align,
      ...props
    },
    forwardedRef
  ) => {
    return (
      <Slot
        ref={forwardedRef}
        className={cn(
          stackVariants({
            spacingX,
            spacingY,
            justify,
            align,
            direction
          }),
          className
        )}
        {...props}
      >
        {asChild ? children : <Tag>{children}</Tag>}
      </Slot>
    );
  }
);

variantsのobjectはJITモードとの兼ね合いでclassNameを動的生成せず愚直に書いてます。

types
src/components/tw/types.ts
export type NumberSpacingProps =
  | 0
  | 1
  | 2
  | 3
  | 4
  | 5
  | 6
  | 7
  | 8
  | 9
  | 10
  | 11
  | 12
  | 14
  | 16
  | 20
  | 24
  | 28
  | 32
  | 36
  | 40
  | 44
  | 48
  | 52
  | 56
  | 60
  | 64
  | 72
  | 80
  | 96
  | 0.5
  | 1.5
  | 2.5
  | 3.5;

export type StringSpacingProps = `${NumberSpacingProps}`;
export type SpacingProps = NumberSpacingProps | StringSpacingProps;
type ExcludeZero<T> = T extends 0 ? never : T;
export type NegativeSpacingProps = `-${ExcludeZero<NumberSpacingProps>}`;

export type StackProps = {
  children: React.ReactNode;
  asChild?: boolean;
  as?: React.ElementType;
  width?: SpacingProps | "full" | "screen";
  height?: SpacingProps | "full" | "screen";
  w?: SpacingProps | "full" | "screen";
  h?: SpacingProps | "full" | "screen";
  spacing?: SpacingProps | NegativeSpacingProps | "px" | "reverse";
  spacingX?: SpacingProps | NegativeSpacingProps | "px" | "reverse";
  spacingY?: SpacingProps | NegativeSpacingProps | "px" | "reverse";
  padding?: SpacingProps | "px";
  p?: SpacingProps | "px";
  px?: SpacingProps;
  py?: SpacingProps;
  pt?: SpacingProps;
  pr?: SpacingProps;
  pb?: SpacingProps;
  pl?: SpacingProps;
  margin?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  m?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  mx?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  my?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  mt?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  mr?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  mb?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  ml?: SpacingProps | NegativeSpacingProps | "px" | "auto";
  justify?: keyof typeof justify;
  align?: keyof typeof align;
  direction?: keyof typeof direction;
  wrap?: keyof typeof wrap;
  color?: ColorProps;
  bgColor?: ColorProps;
  borderColor?: ColorProps;
  borderWidth?: "thin" | "thick";
  rounded?: "none" | "sm" | "md" | "lg" | "full";
  shadow?: "none" | "sm" | "md" | "lg" | "xl" | "2xl";
  position?: PositionProps;
  pos?: "absolute" | "relative" | "fixed" | "static" | "sticky";
  zIndex?: ZIndexProps;
  display?: "flex" | "inlineFlex";
};
functions.ts
src/components/tw/variants/functions.ts
// number型のkeyを持つオブジェクトをstring型に変換する関数
export function convertKeysToString(obj: {
  [key in number | string]: string;
}): {
  [key: string]: string;
} {
  const result: { [key: string]: string } = {};
  for (const key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      result[String(key)] = obj[key];
    }
  }
  return result;
}

// number型のkeyを持つオブジェクトもstring型に変換されずにマージできるようにする関数
export function mergeObjects<T extends Record<number | string, unknown>[]>(
  ...objs: T
): Record<number | string, unknown> {
  const merged: Record<number | string, unknown> = {};
  for (const obj of objs) {
    for (const key in obj) {
      if (Object.hasOwnProperty.call(obj, key)) {
        merged[key] = obj[key];
      }
    }
  }
  return merged;
}
src/components/tw/variants/spacing.ts
const numberSpaceX: Record<NumberSpacingProps, string> = {
  0: "space-x-0",
  1: "space-x-1",
  2: "space-x-2",
  3: "space-x-3",
  4: "space-x-4",
  5: "space-x-5",
  6: "space-x-6",
  7: "space-x-7",
  8: "space-x-8",
  9: "space-x-9",
  10: "space-x-10",
  11: "space-x-11",
  12: "space-x-12",
  14: "space-x-14",
  16: "space-x-16",
  20: "space-x-20",
  24: "space-x-24",
  28: "space-x-28",
  32: "space-x-32",
  36: "space-x-36",
  40: "space-x-40",
  44: "space-x-44",
  48: "space-x-48",
  52: "space-x-52",
  56: "space-x-56",
  60: "space-x-60",
  64: "space-x-64",
  72: "space-x-72",
  80: "space-x-80",
  96: "space-x-96",
  0.5: "space-x-0.5",
  1.5: "space-x-1.5",
  2.5: "space-x-2.5",
  3.5: "space-x-3.5"
};

const negativeSpaceX: Record<NegativeSpacingProps, string> = {
  "-1": "-space-x-1",
  "-2": "-space-x-2",
  "-3": "-space-x-3",
  "-4": "-space-x-4",
  "-5": "-space-x-5",
  "-6": "-space-x-6",
  "-7": "-space-x-7",
  "-8": "-space-x-8",
  "-9": "-space-x-9",
  "-10": "-space-x-10",
  "-11": "-space-x-11",
  "-12": "-space-x-12",
  "-14": "-space-x-14",
  "-16": "-space-x-16",
  "-20": "-space-x-20",
  "-24": "-space-x-24",
  "-28": "-space-x-28",
  "-32": "-space-x-32",
  "-36": "-space-x-36",
  "-40": "-space-x-40",
  "-44": "-space-x-44",
  "-48": "-space-x-48",
  "-52": "-space-x-52",
  "-56": "-space-x-56",
  "-60": "-space-x-60",
  "-64": "-space-x-64",
  "-72": "-space-x-72",
  "-80": "-space-x-80",
  "-96": "-space-x-96",
  "-0.5": "-space-x-0.5",
  "-1.5": "-space-x-1.5",
  "-2.5": "-space-x-2.5",
  "-3.5": "-space-x-3.5"
};

const stringSpaceX = convertKeysToString(numberSpaceX);
export const spaceX = mergeObjects(numberSpaceX, stringSpaceX, negativeSpaceX, {
  px: "space-x-px",
  reverse: "space-x-reverse"
});

// 以下省略 spaceYも同様の記述

Stackコンポーネントの全体像

他のcomponentでも利用できるように各style propsのvariantは外からimportして利用しています。※愚直に書いているだけなのでコードは省略しています

/src/components/tw/Stack.tsx
import { Slot } from "@radix-ui/react-slot";
import React, { forwardRef } from "react";
import { tv } from "tailwind-variants";

import { cn } from "@/lib/utils";

import {
  align,
  height,
  justify,
  margin,
  marginBottom,
  marginLeft,
  marginRight,
  marginTop,
  marginX,
  marginY,
  padding,
  paddingBottom,
  paddingLeft,
  paddingRight,
  paddingTop,
  paddingX,
  paddingY,
  spaceX,
  spaceY,
  width,
  wrap
} from "./variants/";

import type { StackProps } from "./types";

const stackVariants = tv({
  base: "flex",
  variants: {
    spacingX: spaceX,
    spacingY: spaceY,
    width,
    height,
    padding,
    margin,
    mx: marginX,
    my: marginY,
    mt: marginTop,
    mb: marginBottom,
    ml: marginLeft,
    mr: marginRight,
    px: paddingX,
    py: paddingY,
    pt: paddingTop,
    pb: paddingBottom,
    pl: paddingLeft,
    pr: paddingRight,
    justify,
    align,
    direction: {
      horizontal: "flex-row",
      vertical: "flex-col",
      horizontalReverse: "flex-row-reverse",
      verticalReverse: "flex-col-reverse"
    },
    wrap
  },
  defaultVariants: {
    justify: "start",
    align: "start",
    direction: "horizontal",
    wrap: "nowrap"
  }
});

export const Stack = forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLElement> & StackProps
>(
  (
    {
      children,
      className,
      asChild = false,
      as: Tag = "div",
      direction = "horizontal",
      spacing = 2,
      spacingX = direction === "horizontal" ? spacing : undefined,
      spacingY = direction === "vertical" ? spacing : undefined,
      width,
      height,
      w = width,
      h = height,
      padding,
      p = padding,
      px,
      py,
      pt,
      pb,
      pl,
      pr,
      margin,
      m = margin,
      mx,
      my,
      mt,
      mb,
      ml,
      mr,
      justify,
      align,
      ...props
    },
    forwardedRef
  ) => {
    return (
      <Slot
        ref={forwardedRef}
        className={cn(
          stackVariants({
            spacingX,
            spacingY,
            width: w ?? width,
            height: h ?? height,
            padding: p ?? padding,
            px,
            py,
            pt,
            pb,
            pl,
            pr,
            margin: m ?? margin,
            mx,
            my,
            mt,
            mb,
            ml,
            mr,
            justify,
            align,
            direction
          }),
          className
        )}
        {...props}
      >
        {asChild ? children : <Tag>{children}</Tag>}
      </Slot>
    );
  }
);

Stack.displayName = "Stack";

留意事項とまとめ

同様の方法でHeading, TextなどTypography系のコンポーネントも作成可能です。反応があれば他のコンポーネントなども紹介していければと思います。

Spiral.AIではフロントエンドエンジニアも募集しておりますので、Webサイト@hndrへのDMなどでお問い合わせください!

参考にさせていただいた記事

https://yuheiy.com/2023-06-03-react-changeable-element-type-patterns
https://chaika.hatenablog.com/entry/2022/06/22/083000
https://zenn.dev/morinokami/articles/anatomy-of-shadcn-ui
https://zenn.dev/moneyforward/articles/075a74334ca512

脚注
  1. TableなどRadix Primitivesでないものもあります ↩︎

  2. Next.js App RouterのRSC(React Server Components)との相性がよくないため移行 ↩︎

  3. 筆者がTailwind CSSに慣れていなかったというのもあります。 ↩︎

  4. shadcn/uiで使われているutility関数。classNamesの重複の解決、動的に変更しやすくする。同様の関数がTailwind Variantsにもあります。 ↩︎

Spiral.AIテックブログ

Discussion