![[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]]