shadcn/uiでもChakra UIみたいなStackコンポーネントが使いたい
こんにちは 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などに同様のコンポーネントがあります。
shadcn/uiやRadix UIにはこのコンポーネントが存在しないため、当初PandaCSSのStyle propsの利用を考えていましたが、Tailwind CSSのclassName指定と競合しそうであったため独自実装することにしました。
以下、現在実装中のプロダクトで実際に利用しながら適宜修正を加えているコンポーネントの紹介です。
as
propsでタグの種類を変更可能にする
Radixの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を利用しました。
下記のようにpropsで渡された値を、定義した stackVariants
に渡して変換し、cn関数[4]でmergeする形です。
こうすることでTailwind CSSのutilityClassとの重複などが避けられます。
※解説上variantsの定義は絞っています。
tailwindの flexbox
系のutilityClassを利用してvariantを定義していますが、工数や後述の留意事項のため完全互換は目指さず、あくまでChakra UIのStack的な使い方が大体できればよいというところで割り切っています。
また並び方向が固定の HStack
や VStack
なども 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
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
// 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;
}
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して利用しています。※愚直に書いているだけなのでコードは省略しています
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などでお問い合わせください!
参考にさせていただいた記事
Discussion