![[2026-01-19-06-27-00.webp|cover-picture]] [[Class Variance Authority]]を使ったコンポーネント設計について、よくあるパターンについてまとめておく。 - [[#単一variant / 必須プロパティなし(defaultあり) の場合]] - [[#単一variant / 必須プロパティあり(defaultあり) の場合]] - [[#複数variant の場合]] ## 前提 ### 環境 ``` ├── @types/[email protected] ├── @vitejs/[email protected] ├── @vue/[email protected] ├── [email protected] ├── [email protected] ├── [email protected] ├── [email protected] └── [email protected] ``` ### 共通ソース `src/lib/utils.ts` ```ts import type { ClassValue } from "clsx" import { clsx } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ``` `src/lib/types.d.ts` ```ts type RequireVariant<T, K extends keyof T> = Omit<T, K> & { [P in K]-?: NonNullable<T[P]>; }; ``` ### 参考 <div class="link-card-v2"> <div class="link-card-v2-site"> <img class="link-card-v2-site-icon" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" /> <span class="link-card-v2-site-name">Minerva</span> </div> <div class="link-card-v2-title"> 📜2026-01-22 shadcn-vueの設計を紐解く </div> <div class="link-card-v2-content">shadcn-vueを参考に、Vite+Vue3+Tailwind CSS環境を構築したうえで、cn関数やcvaを用いてButtonやLabelコンポーネントのvariant設計とclass競合解消、disabled扱いの検証を行った記録である</div> <img class="link-card-v2-image" src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/Notes/attachments/activity.webp" /> <a data-href="📜2026-01-22 shadcn-vueの設計を紐解く" class="internal-link"></a> </div> %%[[📜2026-01-22 shadcn-vueの設計を紐解く]]%% ## 単一variant / 必須プロパティなし(defaultあり) の場合 `src/components/minerva/label/index.ts` ```ts import { cva, type VariantProps } from "class-variance-authority"; export const labelVariants = cva( [ "flex items-center justify-center", "text-white rounded px-2 py-1", "cursor-pointer", "hover:opacity-90", ], { variants: { variant: { primary: "bg-amber-500", danger: "bg-red-500", }, size: { sm: "text-xs", md: "text-sm", lg: "text-base uppercase", }, }, defaultVariants: { variant: "primary", size: "md", }, }, ); export type LabelVariantProps = VariantProps<typeof labelVariants>; ``` `src/components/minerva/label/MLabel.vue` ```html <script setup lang="ts"> import { labelVariant, type LabelVariantProps, } from "@/components/minerva/label"; import { cn } from "@/lib/utils"; import type { HTMLAttributes } from "vue"; const props = defineProps<{ variant?: LabelVariantProps["variant"]; size?: LabelVariantProps["size"]; class?: HTMLAttributes["class"]; }>(); </script> <template> <div :class="cn(labelVariants({ variant, size }), props.class)"> <slot></slot> </div> </template> ``` ## 単一variant / 必須プロパティあり(defaultあり) の場合 | プロパティ | 必須 | 未指定の場合 | | ------- | --- | ---------------- | | variant | O | エラーになる | | size | | デフォルト値で `md` が入る | | border | | なし | [[#共通ソース]]で定義した `RequireVariant` を使う。 `src/components/minerva/label/index.ts` ```ts import { cva, type VariantProps } from "class-variance-authority"; export const labelVariants = cva( [ "flex items-center justify-center", "text-white rounded px-2 py-1", "cursor-pointer", "hover:opacity-90", ], { variants: { variant: { primary: "bg-amber-500", danger: "bg-red-500", ghost: "text-gray-200 bg-transparent", }, size: { sm: "text-xs", md: "text-sm", lg: "text-base uppercase", }, border: { single: "border-solid border-4 border-gray-300", double: "border-double border-4 border-gray-300", }, }, defaultVariants: { size: "md", }, }, ); export type LabelVariantProps = RequireVariant< VariantProps<typeof labelVariants>, "variant" >; ``` `src/components/minerva/label/MLabel.vue` ```html <script setup lang="ts"> import { labelVariants, type LabelVariantProps, } from "@/components/minerva/label"; import { cn } from "@/lib/utils"; import type { HTMLAttributes } from "vue"; const props = defineProps<{ variant: LabelVariantProps["variant"]; size?: LabelVariantProps["size"]; border?: LabelVariantProps["border"]; class?: HTMLAttributes["class"]; }>(); </script> <template> <div :class="cn(labelVariants({ variant, size, border }), props.class)"> <slot></slot> </div> </template> ``` ### 利用例 ```html <script setup lang="ts"> import MLabel from "@/components/minerva/label/MLabel.vue"; </script> <template> <div class="p-8 flex gap-4"> <MLabel variant="primary">primary</MLabel> <MLabel variant="primary" size="sm">small</MLabel> <MLabel variant="ghost" border="single">ghost single</MLabel> <MLabel variant="ghost" border="double">ghost double</MLabel> </div> </template> ``` ![[2026-02-01-21-20-54.avif|frame]] ## 複数variant の場合 名前空間の切り方は好みが分かれると思うが、コンポーネント名をベースにネストする方式をとる。 `src/components/minerva/gemini-button/index.ts` ```ts import { cva, type VariantProps } from "class-variance-authority"; export const geminiButtonVariants = { first: cva(["border rounded-md px-4 py-2 text-white"], { variants: { variant: { primary: "bg-amber-500", danger: "bg-red-500", }, size: { sm: "text-xs", md: "text-sm", lg: "text-base uppercase", }, }, defaultVariants: { size: "md", }, }), second: cva(["border rounded-md px-4 py-2 text-white"], { variants: { variant: { primary: "bg-sky-500", danger: "bg-violet-500", }, size: { sm: "text-xs", md: "text-sm", lg: "text-base uppercase", }, }, }), }; export type GeminiButtonVariantProps = { First: RequireVariant< VariantProps<typeof geminiButtonVariants.first>, "variant" >; Second: RequireVariant< VariantProps<typeof geminiButtonVariants.second>, "variant" | "size" >; }; ``` `src/components/minerva/gemini-button/GeminiButton.vue` ```html <script setup lang="ts"> import { geminiButtonVariants, type GeminiButtonVariantProps, } from "@/components/minerva/gemini-button"; import { cn } from "@/lib/utils"; import type { HTMLAttributes } from "vue"; const props = defineProps<{ firstVariant: GeminiButtonVariantProps["First"]["variant"]; firstSize?: GeminiButtonVariantProps["First"]["size"]; secondVariant: GeminiButtonVariantProps["Second"]["variant"]; secondSize: GeminiButtonVariantProps["Second"]["size"]; class?: HTMLAttributes["class"]; }>(); </script> <template> <div class="flex gap-2"> <button :class=" cn( geminiButtonVariants.first({ variant: firstVariant, size: firstSize, }), props.class, ) " > <slot></slot> </button> <button :class=" cn( geminiButtonVariants.first({ variant: secondVariant, size: secondSize, }), props.class, ) " > <slot></slot> </button> </div> </template> ``` ### 利用例 ```html <script setup lang="ts"> import GeminiButton from "@/components/minerva/gemini-button/GeminiButton.vue"; </script> <template> <div class="p-8 flex gap-4"> <GeminiButton firstVariant="danger" secondVariant="primary" secondSize="lg" >hoge</GeminiButton > </div> </template> ``` ![[2026-02-01-21-39-03.avif]]