shadcn/uiでChakra UIのようなStackコンポーネントを使用したい
Spiral.AI のエンジニアが、shadcn/ui と Radix Primitives を活用し、Chakra UI 風のスタイルプロパスを持つ独自 Stack コンポーネントを実装・公開した事例について述べている。
キーポイント
Tailwind CSS と Style Props の併存課題
shadcn/ui は Tailwind の utility class を直接使用するが、既存の Chakra UI/MUI プロジェクトからの移行や柔軟なスタイリングのため、props でスタイルを渡す仕組みが必要とされた。
Radix Slot と Tailwind Variants の活用
コンポーネントの要素置換には Radix UI の Slot を利用し、style props を utility class へ変換する処理には Tailwind Variants を採用して型安全性と実装効率を両立させた。
独自 Stack コンポーネントの実装詳細
flexbox の方向(縦横)、アラインメント、間隔制御などを props で制御可能な Stack コンポーネントを実装し、PandaCSS などの競合ライブラリではなく独自実装で解決した。
Slotコンポーネントによる柔軟なレンダリング
@radix-ui/react-slot を使用することで、asChild や as プロパティを通じて任意の HTML タグやコンポーネントへ変換可能にしています。
Tailwind Variants による型安全なスタイル定義
spacingX, spacingY, justify, align などのプロパティを variants で定義し、方向に応じたデフォルト値やクラス名を動的に適用しています。
JIT モード対応のための静的 className 生成
Tailwind の JIT モードとの競合を避けるため、variants オブジェクト内では className を動的に生成せず、愚直に文字列として記述しています。
Chakra UI 風の型安全なSpacing定義
NumberSpacingProps と StringSpacingProps を組み合わせることで、数値と文字列の両方を許容しつつ、0 や負の数(NegativeSpacingProps)を厳密に制御する型定義が実装されています。
影響分析・編集コメントを表示
影響分析
この記事は、shadcn/ui という人気のあるコンポーネントライブラリを使用する際、既存の CSS-in-JS パターン(Chakra UI など)に慣れたエンジニアが直面する「スタイル指定の柔軟性不足」という課題に対する具体的な解決策を示しています。Radix UI の機能と Tailwind Variants を組み合わせることで、utility class の制限を補いながら開発体験を向上させる実用的なパターンを提供しており、フロントエンドアーキテクチャの設計において参考となるケーススタディです。
編集コメント
shadcn/ui の「Headless」な性質を理解した上で、既存のデザインシステムとの互換性を保つための実装パターンが詳しく解説されており、フロントエンドエンジニアにとって非常に参考になる技術記事です。
Tailwind CSS
Styled System
Chakra UI
radix
shadcn/ui techこんにちは 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
https://www.radix-ui.com/primitives/docs/utilities/slot
Radix PrimitivesのSlotコンポーネントを利用すると、下記のようにpropsを渡すことでChakra UIなどと同様に as
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> ); } );
propsに渡されたStyleをTailwind Variantsを利用しTailwindのutilityClassに変換する
shadcn/uiのコンポーネントでも利用されているClass-Variance-Authorityのみでも同様のことはできますが、型の取り回しなどのしやすさなどからTailwind Variantsを利用しました。 https://www.tailwind-variants.org/
下記のようにpropsで渡された値を、定義した stackVariants
tailwindの flexbox
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を動的生成せず愚直に書いてます。
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"; };
// 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(num
原文を表示
Tailwind CSS
Styled System
Chakra UI
radix
shadcn/ui techこんにちは 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
https://www.radix-ui.com/primitives/docs/utilities/slot
Radix PrimitivesのSlotコンポーネントを利用すると、下記のようにpropsを渡すことでChakra UIなどと同様に as
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> ); } );
propsに渡されたStyleをTailwind Variantsを利用しTailwindのutilityClassに変換する
shadcn/uiのコンポーネントでも利用されているClass-Variance-Authorityのみでも同様のことはできますが、型の取り回しなどのしやすさなどからTailwind Variantsを利用しました。 https://www.tailwind-variants.org/
下記のようにpropsで渡された値を、定義した stackVariants
tailwindの flexbox
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を動的生成せず愚直に書いてます。
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"; };
// 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などでお問い合わせください!
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
TableなどRadix Primitivesでないものもあります ↩︎
Next.js App RouterのRSC(React Server Components)との相性がよくないため移行 ↩︎
筆者がTailwind CSSに慣れていなかったというのもあります。 ↩︎
shadcn/uiで使われているutility関数。classNamesの重複の解決、動的に変更しやすくする。同様の関数がTailwind Variantsにもあります。 ↩︎
SpiralAIテックブログPublication実在する芸能人との会話ができる日本初のAIサービス「NaomiAI」やカスタムChatGPTを作れる「Spiralbot」を提供するSpiralAI株式会社のテックブログです。

関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み