## プロジェクト準備
<div class="link-card-v2">
<div class="link-card-v2-site">
<img class="link-card-v2-site-icon" src="https://www.shadcn-vue.com/favicon-16x16.png" />
<span class="link-card-v2-site-name">www.shadcn-vue.com</span>
</div>
<div class="link-card-v2-title">
Vite
</div>
<div class="link-card-v2-content">
Install and configure Vite.
</div>
<img class="link-card-v2-image" src="https://www.shadcn-vue.com/__og-image__/static/docs/installation/vite/og.png" />
<a href="https://www.shadcn-vue.com/docs/installation/vite"></a>
</div>
```
├── @tailwindcss/
[email protected]
├── @vitejs/
[email protected]
├── @vue/
[email protected]
├──
[email protected]
├──
[email protected]
├──
[email protected]
├──
[email protected]
├──
[email protected]
└──
[email protected]
```
[[vite-tsconfig-paths]]を使った方法は `@/` の参照が解決しなかったので [[resolve]] を使った。
## [[cn]]について
[[shadcn-vue]]が[[cn]]という関数を作っている。
`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))
}
```
これは[[clsx]]と[[tailwind-merge]]を合成した関数である。
### [[clsx]]とは
条件をいい感じに適応して有効なclassのみを残すために使用。
```html
<script setup lang="ts">
import clsx from "clsx";
const mergedClass = clsx("base");
</script>
<template>
<div :class="mergedClass"></div>
</template>
```
これは以下のようになる。
```html
<div class="base"></civ>
```
`mergedClass` の値を変えて `class` の変化を見ていく。
```ts
const mergedClass = clsx("base", true && "yes");
// "base yes"
const mergedClass = clsx("base", false && "yes");
// "base"
const mergedClass = clsx("base", {
"variant-a": true,
"variant-b": false,
});
// "base variant-a"
const mergedClass = clsx("base", {});
// "base"
const mergedClass = clsx("base", ["va", "", false, null, undefined, "vb"]);
// "base va vb"
const mergedClass = clsx("base", [
"va",
["vb", false],
{ vc: true, vd: false },
]);
// base va vb vc
```
### [[tailwind-merge]]とは
```html
<script setup lang="ts">
const mergedClass = "p-3 p-4";
</script>
<template>
<div :class="mergedClass"></div>
</template>
```
これは以下のようになる。`p-3` と `p-4` が共存してしまってよろしくない。
```html
<div class="p-3 p-4"></div>
```
`twMerge` を使うとこうなる。
```html
<script setup lang="ts">
import { twMerge } from 'tailwind-merge';
const mergedClass = twMerge("p-3 p-4");
</script>
<template>
<div :class="mergedClass"></div>
</template>
```
```html
<div class="p-4"></div>
```
`mergedClass` の値を変えて変化を見ていく。
```ts
// 基本は後勝ち
const mergedClass = twMerge("p-3 p-4");
// "p-4"
const mergedClass = twMerge("p-4 p-3");
// "p-3"
// 可変長の指定
const mergedClass = twMerge("p-4", "p-3");
// "p-3"
const mergedClass = twMerge("p-4 p-5", "p-3 p-6");
// "p-6"
```
### [[cn]]とは
改めて[[cn]]を見る。
`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))
}
```
`ClassValue` は[[配列 (JavaScript)|配列]]や[[オブジェクト (JavaScript)|オブジェクト]]を含んだクラス表現はすべて受け付ける。
```ts
export type ClassValue = ClassArray | ClassDictionary | string | number | bigint | null | boolean | undefined;
```
つまり、[[cn]]に可変長で適当に放り込んでおけばよしなにやってくれる。
```ts
const mergedClass = cn(
"text-red-200",
{ "text-blue-300": true },
["", "bg-green-500", false, {}],
{},
"inline-flex",
["flex", ["items-center", ["justify-center"]]],
);
// "text-blue-300 bg-green-500 flex items-center justify-center"
```
## [[cva]]について
[[Class Variance Authority]]の関数[[cva]]を使って、独自のButtonコンポーネントを設計してみる。まずは `.ts` ファイルで `buttonVariant` 関数を作成。
`src/components/minerva/button/index.ts`
```ts
const buttonVariant = cva(["text-gray-400", "rounded"], {
variants: {
// variantと呼ばれることも多い
intent: {
primary: ["bg-blue-600", "text-white"],
danger: ["bg-red-600", "text-white"],
},
size: {
small: ["px-3", "py-1", "text-sm"],
medium: ["px-4", "py-2", "text-base"],
large: ["px-5", "py-3", "text-lg"],
},
// disabled がついているときだけ特定のスタイルを適用
disabled: {
true: ["opacity-50", "cursor-not-allowed"],
false: [],
},
},
defaultVariants: {
size: "medium",
disabled: false,
},
});
export { buttonVariant };
```
引数に指定したい `variants` の各要素を指定すると、対象要素のクラス名を複数返してくれる。
```ts
buttonVariant() // size: "medium"
// text-gray-400 rounded px-4 py-2 text-base
buttonVariant({ size: "large" })
// text-gray-400 rounded px-5 py-3 text-lg
buttonVariant({ size: "large", intent: "danger" })
// text-gray-400 rounded bg-red-600 text-white px-5 py-3 text-lg
buttonVariant({ size: "large", intent: "danger", disabled: true })
// text-gray-400 rounded bg-red-600 text-white px-5 py-3 text-lg opacity-50 cursor-not-allowed
```
### 競合の解消
このときは `text-gray-400` と `text-white` のように**競合するもの**も存在する。
```ts
buttonVariant({ size: "large", intent: "danger", disabled: true })
// text-gray-400 rounded bg-red-600 text-white px-5 py-3 text-lg opacity-50 cursor-not-allowed
```
[[cn]]を使って競合を解消する。
```ts
cn(buttonVariant({ size: "large", intent: "danger", disabled: true }))
// rounded bg-red-600 text-white px-5 py-3 text-lg opacity-50 cursor-not-allowed
```
[[cva]]では `base` よりも指定した `variants` のvariantに紐づくクラスの方が優先される。クラス名が後ろに追加され、[[cn]]([[tailwind-merge]])では後に指定されたクラスが優先されるため。
これをそのまま `:class` に指定できる。
```html
<script setup lang="ts">
import {
buttonVariant,
} from "@/components/minerva/button";
import { cn } from "@/lib/utils";
</script>
<template>
<button
:class="
cn(buttonVariant({ size: 'large', intent: 'danger', disabled: true }))
"
>
ボタン
</button>
</template>
```
### スマートな[[props (Vue)|props]]指定
`variants` の各variantは、[[Vue]]の[[props (Vue)|props]]で指定できる方がスマートである。[[VariantProps型]]を使って、各variantの型を定義できる。
`src/components/minerva/button/index.ts`
```ts
// 中略
export { buttonVariant };
export type ButtonVariantProps = VariantProps<typeof buttonVariant>;
// type ButtonVariantProps = {
// intent?: "primary" | "danger" | null | undefined;
// size?: "small" | "medium" | "large" | null | undefined;
// disabled?: boolean | null | undefined;
// }
```
vueファイルでは[[インデックス型]]を使う。[[VariantProps型はすべてのプロパティがOptional]]となるが、このように分離することで、[[Nullish (JavaScript)|Nullish]]ではあるがrequiredの型へと昇格できる。
`src/components/minerva/button/MButton.vue`
```html
<script setup lang="ts">
import { buttonVariant, type ButtonVariantProps } from "@/components/minerva/button";
import { cn } from "@/lib/utils";
defineProps<{
// required
intent: ButtonVariantProps["intent"];
// 以下はoptional
size?: ButtonVariantProps["size"];
disabled?: ButtonVariantProps["disabled"];
}>();
</script>
```
ここで、先ほど[[#競合の解消]]の最後で紹介したコードを思い出してみる。
```html
<script setup lang="ts">
import {
buttonVariant,
} from "@/components/minerva/button";
import { cn } from "@/lib/utils";
</script>
<template>
<button
:class="
cn(buttonVariant({ size: 'large', intent: 'danger', disabled: true }))
"
>
ボタン
</button>
</template>
```
合成するとこのようになり
`src/components/minerva/button/MButton.vue`
```html
<script setup lang="ts">
import {
buttonVariant,
type ButtonVariantProps,
} from "@/components/minerva/button";
import { cn } from "@/lib/utils";
defineProps<{
intent: ButtonVariantProps["intent"];
size: ButtonVariantProps["size"];
disabled?: ButtonVariantProps["disabled"];
}>();
</script>
<template>
<button
:class="
cn(buttonVariant({ intent: intent, size: size, disabled: disabled }))
"
>
ボタン
</button>
</template>
```
[[拡張オブジェクトリテラル (JavaScript)|拡張オブジェクトリテラル]]でさらに簡略化して、[[スロットアウトレット (Vue)|スロットアウトレット]]を使うとこうなる。
`src/components/minerva/button/MButton.vue`
```html
<script setup lang="ts">
import {
buttonVariant,
type ButtonVariantProps,
} from "@/components/minerva/button";
import { cn } from "@/lib/utils";
defineProps<{
intent: ButtonVariantProps["intent"];
size: ButtonVariantProps["size"];
disabled?: ButtonVariantProps["disabled"];
}>();
</script>
<template>
<button :class="cn(buttonVariant({ intent, size, disabled }))">
<slot></slot>
</button>
</template>
```
そして `MButton` はコンポーネントとなる。
`src/App.vue`
```html
<script setup lang="ts">
import MButton from "@/components/minerva/button/MButton.vue";
</script>
<template>
<MButton intent="primary" size="medium">クリック me</MButton>
</template>
```
## [[shadcn-vue]]の味付け
[[shadcn-vue]]の `Button.vue` を見てみよう。
```html
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>
```
[[Primitive (Reka UI)|Primitive]]は[[Reka UI]]が提供するコンポーネントだが、ここでは実質buttonだと思ってもらって問題ない。問題を単純化するためコードを変更する。
`src/components/ui/button/Button2.vue`
```html
<script setup lang="ts">
import { cn } from "@/lib/utils";
import { Primitive } from "reka-ui";
import type { HTMLAttributes } from "vue";
import type { ButtonVariants } from ".";
import { buttonVariants } from ".";
interface Props {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
}
const props = withDefaults(defineProps<Props>(), {});
</script>
<template>
<Primitive :class="cn(buttonVariants({ variant, size }), props.class)">
<slot />
</Primitive>
</template>
```
[[Reka UI]]の[[Primitive (Reka UI)|Primitive]]、[[PrimitiveProps型 (Reka UI)|PrimitiveProps型]]を削除した。残るは `class` だけになる。
[[HTMLAttributes型 (Vue)|HTMLAttributes型]]の[[インデックス型]] `HTMLAttributes["class"]` は[[class属性 (HTML)|class属性]]が受け付ける型であり`any` 型。これはそのまま[[cn]]に流れ込むので、様々な表現を指定できる。
よって上記のコンポーネント `Button2` は以下の様に利用できる。
```html
<Button2 variant="destructive" size="sm" class="rounded-full font-bold">
ボタン
</Button2>
```
### MButtonへの適応
このようになる。
```html
<script setup lang="ts">
import {
buttonVariant,
type ButtonVariantProps,
} from "@/components/minerva/button";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
intent: ButtonVariantProps["intent"];
size: ButtonVariantProps["size"];
disabled?: ButtonVariantProps["disabled"];
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<button :class="cn(buttonVariant({ intent, size, disabled }), props.class)">
<slot></slot>
</button>
</template>
```
`props` は省略できそうに見えるが、`class` が構文エラーとなってしまうので必須。
```html
<!-- '{' expected. [1005] になる -->
<button :class="cn(buttonVariant({ intent, size, disabled }), class)">
<slot></slot>
</button>
```
## より複雑な条件
複数のvariantの状態によって、variantの値が変わる場合は[[Compound Variants]]を使う。
```ts
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariant = cva(["text-gray-400", "rounded"], {
variants: {
// variantと呼ばれることも多い
intent: {
primary: ["bg-blue-600 hover:bg-blue-600/50", "text-white"],
danger: ["bg-red-600 hover:bg-red-600/50", "text-white"],
},
size: {
small: ["px-3", "py-1", "text-sm"],
medium: ["px-4", "py-2", "text-base"],
large: ["px-5", "py-3", "text-lg"],
},
// disabled がついているときだけ特定のスタイルを適用
disabled: {
true: ["opacity-50", "cursor-not-allowed"],
false: [],
},
},
// 追加
compoundVariants: [
// intent="danger" size="large" の場合のみ大文字
{
intent: "danger",
size: "large",
class: "uppercase",
},
],
defaultVariants: {
size: "medium",
disabled: false,
},
});
export { buttonVariant };
export type ButtonVariantProps = VariantProps<typeof buttonVariant>;
```
```html
<script setup lang="ts">
import MButton from "@/components/minerva/button/MButton.vue";
</script>
<template>
<!-- これは小文字のボタン -->
<MButton size="large" intent="primary" class="rounded-full font-bold">
primary
</MButton>
<!-- これは大文字のボタン -->
<MButton size="large" intent="danger" class="rounded-full font-bold" disabled>
danger
</MButton>
</template>
```
## disabledとbooleanについて
少し話をシンプルにするため、`intent` と `disabled` 以外を削除する。
`src/components/minerva/button/index.ts`
```ts
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariant = cva(["text-gray-400", "rounded", "px-2 py-1"], {
variants: {
intent: {
primary: ["bg-blue-600 hover:bg-blue-600/50", "text-white"],
danger: ["bg-red-600 hover:bg-red-600/50", "text-white"],
},
disabled: {
true: ["opacity-50", "cursor-not-allowed"],
false: [],
},
},
});
export { buttonVariant };
export type ButtonVariantProps = VariantProps<typeof buttonVariant>;
```
`src/components/minerva/button/MButton.vue`
```html
<script setup lang="ts">
import {
buttonVariant,
type ButtonVariantProps,
} from "@/components/minerva/button";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
intent: ButtonVariantProps["intent"];
disabled?: ButtonVariantProps["disabled"];
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<div>{{ disabled }}</div>
<button
:class="cn(buttonVariant({ intent, disabled }), props.class)"
@click="console.log('hoge')"
>
<slot></slot>
</button>
</template>
```
### disabledが有効にならない
このとき、`disabled` を以下のように指定する。
`src/App.vue`
```html
<script setup lang="ts">
import MButton from "@/components/minerva/button/MButton.vue";
</script>
<template>
<div class="p-8">
<MButton intent="primary" disabled> primary </MButton>
</div>
</template>
```
しかし、この `disabled` は有効にならない。warningも出る。
```warning
MButton.vue?t=1769248386821:66 [Vue warn]: Missing required prop: "intent"
at <MButton disabled="" >
at <App>
```
![[2026-01-24-18-58-28.avif]]
`<... disabled />` としただけでは `disabled` は空文字になる。
これを `ButtonVariantProps` の[[インデックス型]]を使わずに、直接同じ推論結果を指定する。
```diff
const props = defineProps<{
intent: ButtonVariantProps["intent"];
- disabled?: ButtonVariantProps["disabled"];
+ disabled?: boolean | null | undefined;
class?: HTMLAttributes["class"];
}>();
```
これはちゃんと効く。
![[2026-01-24-18-57-46.avif]]
### disabledをvariantとして定義しない
`ButtonVariantProps` を使わなければ解決する話だが、若干気持ち悪い。これは[[Vue]]の [[defineProps (Vue)|defineProps]]の書き方による仕様の1つなので、そもそもvariantに`disabled`を用意せず、[[Tailwind CSS]]のクラスとして扱ったほうがよいのではないか。
[[shadcn-vue]]の `buttonVariants` 生成ロジックを見てみる。
```ts
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
```
このように[[cva]]へ投げ込むクラスの中で `disabled:pointer-events-none disabled:opacity-50` のような記載がある。
同じように実装してみる。
`src/components/minerva/button/index.ts`
```ts
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariant = cva(
[
"text-gray-400",
"rounded",
"px-2 py-1",
"disabled:opacity-50 disabled:cursor-not-allowed",
],
{
variants: {
intent: {
primary: ["bg-blue-600 hover:bg-blue-600/50", "text-white"],
danger: ["bg-red-600 hover:bg-red-600/50", "text-white"],
},
},
},
);
export { buttonVariant };
export type ButtonVariantProps = VariantProps<typeof buttonVariant>;
```
`src/components/minerva/button/MButton.vue`
```html
<script setup lang="ts">
import {
buttonVariant,
type ButtonVariantProps,
} from "@/components/minerva/button";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
const props = defineProps<{
intent: ButtonVariantProps["intent"];
class?: HTMLAttributes["class"];
}>();
</script>
<template>
<button
:class="cn(buttonVariant({ intent }), props.class)"
@click="console.log('hoge')"
>
<slot></slot>
</button>
</template>
```
これで期待通り動くようになった。
## 最小構成のテンプレ
最後に `MLabel` という名前の最小構成コンポーネントを雛形として作っておく。最小とは言ったものの、よく使うパターンは入れているつもり。
`src/components/minerva/label/index.ts`
```ts
import { cva, type VariantProps } from "class-variance-authority";
const labelVariant = 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",
},
},
// compoundVariants: [],
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
export { labelVariant };
export type LabelVariantProps = VariantProps<typeof labelVariant>;
```
`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(labelVariant({ variant, size }), props.class)">
<slot></slot>
</div>
</template>
```
なお、style(class)がかかる[[HTMLタグ]]が複数に及ぶケースでは、その数だけ `oooVariant` をつくることになる。ここまでの理論を理解できていれば、やることは基本的に同じなので詳細は割愛。