はじめに
デザインシステムのコンポーネントライブラリを提供する際に遭遇しやすい課題の一つが「一貫性の担保」です。
<!-- 使う側によってclassが上書きされる -->
<MyButton class="mt-1 text-red-500">
送信
</MyButton>
統一されたデザインを提供するために作ったコンポーネントが、追加のスタイルで意図しない見た目や振る舞いになる...。このような問題は、デザインシステムの価値である「一貫性」を根本から揺るがします。
そこで、デザインシステムを作る側の視点で、コンポーネントの責務を明確にし、長期的な保守性を高めるアプローチを考えてみました。
Vueのフォールスルー属性とデフォルト挙動
Vueでは、コンポーネントに渡された属性のうちpropsやemitsで宣言されていないものは、フォールスルー属性としてroot要素に自動的に適用されます。
<!-- MyButton.vue -->
<script setup lang="ts">
defineProps<{
variant: 'primary' | 'secondary';
}>();
</script>
<template>
<button class="px-4 py-2 rounded bg-blue-600 text-white">
<slot />
</button>
</template>
<MyButton variant="primary" class="extra-class" data-testid="submit-btn">
送信
</MyButton>
<!-- レンダリング結果 -->
<button class="px-4 py-2 rounded bg-blue-600 text-white extra-class" data-testid="submit-btn">送信</button>
この挙動は一般的なコンポーネント開発では便利ですが、デザインシステムという文脈では制御範囲外の副作用 (想定されていない使われ方) という位置づけになります。
デザインシステムで起きる問題
外部からclassやstyleが渡されると、デザインシステムで定義したスタイルが上書きされます。
<!-- 意図していない"調整" -->
<MyButton class="text-red-500">
削除
</MyButton>
このような調整が積み重なると、プロダクト全体の視覚的一貫性が失われます。また、これらの調整がどこでどのように行われたかの追跡も困難になります。
本来コンポーネント内部で制御すべき属性が、外部から上書きされてしまうリスクもあります。
<!-- 内部実装との競合 -->
<MyButton tabindex="-1" onclick="alert('override')">
送信
</MyButton>
tabindexやイベントハンドラのような、アクセシビリティや動作に関わる属性が外部から注入されると、コンポーネントの設計意図が損なわれます。
「どの属性が渡されるか」が明示されていない状態は、コンポーネントのインターフェースを曖昧にし、長期間運用する場合のメンテナンスコストを増大させます。
inheritAttrs: false で制御する
この問題に対する解決策として、inheritAttrs: falseを設定します。
<!-- MyButton.vue -->
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
defineProps<{
variant: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
}>();
</script>
<template>
<button
:class="[
'px-4 py-2 rounded',
variant === 'primary' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-900',
size === 'sm' && 'px-3 py-1 text-sm',
size === 'lg' && 'px-6 py-3 text-lg',
]"
:disabled="disabled"
>
<slot />
</button>
</template>
これにより、propsで明示的に定義していない属性はすべて無視されます。
<MyButton variant="primary" class="ignored" style="color: red">
送信
</MyButton>
<!-- レンダリング結果: classとstyleは適用されない -->
<button class="px-4 py-2 rounded bg-blue-600 text-white">送信</button>
外部から渡されたclassやstyleは無視され、デザインシステムで定義されたスタイルが保たれます。スタイルの一貫性を強制でき、コンポーネントのインターフェースを明確にすることで、将来の変更時の影響範囲を限定できます。
選択的に属性を許可する
完全に属性をブロックすることが常に最適とは限りません。アクセシビリティやテスト目的で、特定の属性パターンは許可したい場合があります。
useAttrsを使い、許可する属性をフィルタリングする実装を見ていきます。
<script setup lang="ts">
import { computed, useAttrs } from 'vue';
defineOptions({
inheritAttrs: false,
});
defineProps<{
variant: 'primary' | 'secondary';
}>();
const attrs = useAttrs();
// aria-* と data-* のみ許可
const allowedAttrs = computed(() => {
return Object.fromEntries(
Object.entries(attrs).filter(([key]) => key.startsWith('aria-') || key.startsWith('data-'))
);
});
</script>
<template>
<button :class="variant === 'primary' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-900'" v-bind="allowedAttrs">
<slot />
</button>
</template>
この実装により、以下のような選択的な許可が可能になります。
<MyButton variant="primary" class="ignored" style="color: red" aria-label="送信する" data-testid="submit-btn">
送信
</MyButton>
<!-- レンダリング結果 -->
<button class="bg-blue-600 text-white" aria-label="送信する" data-testid="submit-btn">送信</button>
classとstyleは拒否され、aria-labelとdata-testidは適用されます。
許可パターンをユーティリティ化する
複数のコンポーネントで同じフィルタリングロジックを使う場合、Composableとして切り出すと便利です。
// composables/useFilteredAttrs.ts
import { computed, useAttrs } from 'vue';
type AllowPattern = 'aria' | 'data' | 'on';
export function useFilteredAttrs(allow: AllowPattern[] = ['aria', 'data']) {
const attrs = useAttrs();
const prefixMap: Record<AllowPattern, string> = {
aria: 'aria-',
data: 'data-',
on: 'on',
};
return computed(() => {
return Object.fromEntries(
Object.entries(attrs).filter(([key]) => allow.some((pattern) => key.startsWith(prefixMap[pattern])))
);
});
}
<script setup lang="ts">
import { useFilteredAttrs } from '@/composables/useFilteredAttrs';
defineOptions({
inheritAttrs: false,
});
// aria-* と data-* を許可
const filteredAttrs = useFilteredAttrs(['aria', 'data']);
</script>
<template>
<button class="px-4 py-2 rounded bg-blue-600 text-white" v-bind="filteredAttrs">
<slot />
</button>
</template>
TypeScriptでの型表現
実行時の制御に加え、型レベルで制約を表現することも可能です。
<script setup lang="ts">
defineOptions({
inheritAttrs: false,
});
defineProps<{
variant: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
// 許可する属性は明示的にpropsで定義
'aria-label'?: string;
'aria-describedby'?: string;
}>();
</script>
ただし、Vueの型システムではinheritAttrs: falseの設定に関わらず、任意の属性を渡すことが型レベルでは許容されます。完全に型で縛るには追加の型定義が必要です。
// types/strict-props.ts
type StrictProps<T> = T & {
class?: never;
style?: never;
};
しかし、この方法は型定義の複雑化によるメンテナンスコストと、得られる型安全性のトレードオフを考慮する必要があります。
多くの場合、inheritAttrs: falseによるランタイムレベルの制御 + 明示的なドキュメントの組み合わせで十分な効果が得られると考えます。型による完全な制約は、開発組織のTypeScriptの習熟度やプロジェクトの要求する厳密性のレベルに応じて判断するべきでしょう。
コンポーネントごとの判断基準
すべてのコンポーネントで同じポリシーを適用する必要はありません。
Button、Card、Inputのような見た目に関わるコンポーネントは厳格に制御します。外部からの class や style は受け付けず、バリエーションはすべてpropsで表現する方針です。ModalやDialogも同様で、z-indexやポジショニングへの干渉は表示崩れに直結するため、完全にブロックします。
一方、Stack、Flex、Gridのようなレイアウト系は配置の微調整が必要になる場面が現実的に多く、 class による調整を許容する余地があります。
aria-* と data-* については、基本的に許可で良いと考えています。アクセシビリティとテスタビリティを犠牲にしてまで一貫性を守る意味はないからです。
おわり
デザインシステムの価値は「何でもできる柔軟性」よりも「一貫性の保証」にあります。
VueのinheritAttrs: falseを活用することで、スタイルの一貫性を技術的にも担保し、コンポーネントの長期的な保守性を向上させることができます。
「制約」という言葉はネガティブに捉えられやすいですが、適切に設計された制約は認知負荷の軽減にも寄与し、「どう使えばいいか」に迷わないためのガイドラインとして機能します。