vue
Skill50 starsChanged 11 months agoMITView on GitHub
Builds token-driven Vue 3 components with Composition API and TypeScript. Use when creating Vue component libraries, integrating design tokens, or building Nuxt design system components with composables.
33 other projects use a version of this.
Adopted by
Most recently taken up by
- Contentrain/studiotheir own version, first seen Nov 8, 2026
- antfu/skillstheir own version, first seen Nov 5, 2026
- onmax/nuxt-skillstheir own version, first seen Nov 1, 2026
- vercel-labs/json-rendertheir own version, first seen Oct 29, 2026
- lirantal/slides-toxicskills-agent-skills-malwaretheir own version, first seen Oct 25, 2026
- KIMJINWOO4/vue-skillstheir own version, first seen Oct 15, 2026
Used with
Projects with it also use antfu-design, nitro and 8 more.
Other files people use alongside this one.
Files found in the same public projects as a version of this one, more often than they turn up anywhere else. Each project counts once, large collections count for less, and this file's own repository is left out.
wltn7810-dotcomSkillantfu-design
antfu-style design conventions, broadened. UnoCSS-first, class-based semantic tokens with dual light/dark for tooling and devtools UIs, plus design-read, anti-slop, and micro-interaction polish for landing pages and product surfaces. Use when building or refactoring any interface with UnoCSS.
Also in 3 projects that use this
SentrySkillnitro
Build and deploy universal JavaScript servers with Nitro v3. Use when working with nitro.config.ts, defineNitroConfig, defineHandler, defineConfig, server.ts entry, filesystem routing, route rules, useStorage, defineCachedHandler, useDatabase, definePlugin, runtime hooks, Vercel/Cloudflare deployment, or migrating from Nitro v2/nitropack.
Also in 4 projects that use this
rogue-dev-studioSkillnodejs
Node.js development environment. Use when the project needs this capability or the user / team manifest asks for it. Use for specialized `nodejs` work when listed in TEAM.yaml or explicitly requested.
Also in 4 projects that use this
andyridsSkillexpress
Run the ICM express-change pipeline - a small change no spec has to move for, with the plan record left intact
Also in 3 projects that use this
Sorcha-PlatformSkillgrpc
Build or review gRPC services and clients in .NET. USE FOR: ASP.NET Core gRPC, protobuf contracts, unary or streaming RPC, gRPC client factory, interceptors, deadlines, cancellation, channel reuse, backend service integration. DO NOT USE FOR: broad browser-facing APIs without gRPC-Web tradeoff review, SignalR realtime hubs, plain REST APIs. INVOKES: dotnet build/test and focused service or client smoke checks when code changes.
Also in 3 projects that use this
MicrosoftSkilldjango
Best practices for Django web development including models, views, templates, and testing.
Also in 4 projects that use this
Other versions33 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 33
- moeru-ai/airi★ 51ksame name
- vercel-labs/json-render★ 19ksame name
- antfu/skills★ 6.1ksame name
- onmax/nuxt-skills★ 724same name
- oyjt/uniapp-vue3-template★ 628same name
- victorgarciaesgi/regle★ 508same name
- JetBrains/skills★ 386same name
- konnga/skill-buddy★ 128same name
- uni-helper/skills★ 107same name
- nimadorostkar/Claude-Skills-collection★ 33same name
23 more not listed
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository29 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- aria-patternsSkill
- color-contrastSkill
- focus-statesSkill
- component-docsSkill
- token-docsSkill
- angularSkill
- reactSkill
- svelteSkill
- getting-startedSkill
- animation-principlesSkill
- compound-componentsSkill
- dark-modeSkill
- icon-systemSkill
- layout-primitivesSkill
- breakpointsSkill
- color-scaleSkill
- design-tokens-structureSkill
- motion-scaleSkill
- radius-scaleSkill
- responsive-typographySkill
- shadow-scaleSkill
- spacing-scaleSkill
- type-scaleSkill
- z-index-scaleSkill
- figmaSkill
- framerSkill
- storybookSkill
- style-dictionarySkill
The file
We split this skill into the rules it gives an agent
1 rule. 0 are common, 0 are distinctive, and 1 is very specific to this project.
0
Common
Most of the 28 edited versions we read keep these.
0
Distinctive
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 23
Define props with TypeScript: Use defineProps with interfaces
“Common” is measured: kept by at least 6 in 10 of the 28 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Vue Component Patterns
Overview
Build accessible, token-driven Vue 3 components using Composition API and modern patterns. Covers SFC structure, TypeScript integration, composables, and consuming design tokens.
When to Use
- Creating a Vue 3 component library
- Building components that use design tokens
- Setting up a design system in Vue/Nuxt
- Converting designs to Vue components
The Process
- Identify component type: Primitive, composite, or layout?
- Choose styling approach: Scoped CSS, CSS Modules, Tailwind, or UnoCSS?
- Define props with TypeScript: Use
definePropswith interfaces - Implement with tokens: CSS custom properties or provide/inject
- Add accessibility: ARIA, keyboard handling, focus management
- Create composables: Extract reusable logic
Project Structure
src/
├── components/
│ ├── primitives/
│ │ ├── VButton/
│ │ │ ├── VButton.vue
│ │ │ ├── VButton.test.ts
│ │ │ └── index.ts
│ │ ├── VInput/
│ │ └── VText/
│ ├── composite/
│ │ ├── VCard/
│ │ ├── VModal/
│ │ └── VDropdown/
│ └── layout/
│ ├── VStack/
│ ├── VGrid/
│ └── VContainer/
├── composables/
│ ├── useTheme.ts
│ ├── useBreakpoint.ts
│ └── useId.ts
├── tokens/
│ └── index.css
└── index.tsComponent Patterns
Button Component
VButton.vue:
<script setup lang="ts">
import { computed, useSlots } from 'vue';
export interface ButtonProps {
/** Visual style variant */
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
/** Size of the button */
size?: 'sm' | 'md' | 'lg';
/** Full width button */
fullWidth?: boolean;
/** Loading state */
loading?: boolean;
/** Disabled state */
disabled?: boolean;
/** HTML button type */
type?: 'button' | 'submit' | 'reset';
}
const props = withDefaults(defineProps<ButtonProps>(), {
variant: 'primary',
size: 'md',
fullWidth: false,
loading: false,
disabled: false,
type: 'button',
});
const emit = defineEmits<{
click: [event: MouseEvent];
}>();
const slots = useSlots();
const isDisabled = computed(() => props.disabled || props.loading);
const classes = computed(() => [
'btn',
`btn--${props.variant}`,
`btn--${props.size}`,
{
'btn--full-width': props.fullWidth,
'btn--loading': props.loading,
},
]);
function handleClick(event: MouseEvent) {
if (!isDisabled.value) {
emit('click', event);
}
}
</script>
<template>
<button
:type="type"
:class="classes"
:disabled="isDisabled"
:aria-busy="loading"
@click="handleClick"
>
<span v-if="loading" class="btn__spinner" aria-hidden="true" />
<span v-if="slots.leftIcon" class="btn__icon">
<slot name="leftIcon" />
</span>
<span class="btn__label">
<slot />
</span>
<span v-if="slots.rightIcon" class="btn__icon">
<slot name="rightIcon" />
</span>
</button>
</template>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
font-family: inherit;
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: pointer;
user-select: none;
border: 1px solid transparent;
border-radius: var(--radius-md);
transition:
background-color 150ms ease,
border-color 150ms ease,
transform 100ms ease;
}
.btn:focus-visible {
outline: 2px solid var(--color-primary-500);
outline-offset: 2px;
}
.btn:active:not(:disabled) {
transform: scale(0.98);
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Variants */
.btn--primary {
background-color: var(--color-primary-500);
color: white;
}
.btn--primary:hover:not(:disabled) {
background-color: var(--color-primary-600);
}
.btn--secondary {
background-color: transparent;
border-color: var(--color-gray-300);
color: var(--color-gray-700);
}
.btn--secondary:hover:not(:disabled) {
background-color: var(--color-gray-50);
border-color: var(--color-gray-400);
}
.btn--ghost {
background-color: transparent;
color: var(--color-gray-700);
}
.btn--ghost:hover:not(:disabled) {
background-color: var(--color-gray-100);
}
.btn--danger {
background-color: var(--color-error-500);
color: white;
}
.btn--danger:hover:not(:disabled) {
background-color: var(--color-error-600);
}
/* Sizes */
.btn--sm {
height: 32px;
padding: 0 var(--spacing-sm);
font-size: var(--text-sm);
}
.btn--md {
height: 40px;
padding: 0 var(--spacing-md);
font-size: var(--text-base);
}
.btn--lg {
height: 48px;
padding: 0 var(--spacing-lg);
font-size: var(--text-lg);
}
/* Modifiers */
.btn--full-width {
width: 100%;
}
.btn--loading .btn__label {
opacity: 0;
}
.btn__spinner {
position: absolute;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.btn__icon {
display: flex;
flex-shrink: 0;
}
</style>Input Component
VInput.vue:
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useId } from '@/composables/useId';
export interface InputProps {
/** v-model value */
modelValue?: string;
/** Label text */
label?: string;
/** Placeholder text */
placeholder?: string;
/** Helper text */
helperText?: string;
/** Error message */
error?: string;
/** Input type */
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
/** Size variant */
size?: 'sm' | 'md' | 'lg';
/** Disabled state */
disabled?: boolean;
/** Required field */
required?: boolean;
/** Full width */
fullWidth?: boolean;
}
const props = withDefaults(defineProps<InputProps>(), {
modelValue: '',
type: 'text',
size: 'md',
disabled: false,
required: false,
fullWidth: false,
});
const emit = defineEmits<{
'update:modelValue': [value: string];
blur: [event: FocusEvent];
focus: [event: FocusEvent];
}>();
const slots = defineSlots<{
startAdornment?: () => any;
endAdornment?: () => any;
}>();
const inputId = useId();
const helperId = computed(() => `${inputId}-helper`);
const errorId = computed(() => `${inputId}-error`);
const inputRef = ref<HTMLInputElement>();
const classes = computed(() => [
'input-wrapper',
`input-wrapper--${props.size}`,
{
'input-wrapper--error': props.error,
'input-wrapper--disabled': props.disabled,
'input-wrapper--full-width': props.fullWidth,
},
]);
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
emit('update:modelValue', target.value);
}
function focus() {
inputRef.value?.focus();
}
defineExpose({ focus, inputRef });
</script>
<template>
<div :class="['input-container', { 'input-container--full-width': fullWidth }]">
<label v-if="label" :for="inputId" class="input-label">
{{ label }}
<span v-if="required" class="input-required" aria-hidden="true">*</span>
</label>
<div :class="classes">
<span v-if="slots.startAdornment" class="input-adornment">
<slot name="startAdornment" />
</span>
<input
:id="inputId"
ref="inputRef"
:value="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:required="required"
:aria-invalid="!!error"
:aria-describedby="error ? errorId : helperText ? helperId : undefined"
class="input"
@input="handleInput"
@blur="emit('blur', $event)"
@focus="emit('focus', $event)"
/>
<span v-if="slots.endAdornment" class="input-adornment">
<slot name="endAdornment" />
</span>
</div>
<span v-if="error" :id="errorId" class="input-error" role="alert">
{{ error }}
</span>
<span v-else-if="helperText" :id="helperId" class="input-helper">
{{ helperText }}
</span>
</div>
</template>
<style scoped>
.input-container {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.input-container--full-width {
width: 100%;
}
.input-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-700);
}
.input-required {
color: var(--color-error-500);
margin-left: 2px;
}
.input-wrapper {
display: flex;
align-items: center;
border: 1px solid var(--color-gray-300);
border-radius: var(--radius-md);
background-color: white;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.input-wrapper:focus-within {
border-color: var(--color-primary-500);
box-shadow: 0 0 0 3px rgb(59 130 246 / 0.15);
}
.input-wrapper--error {
border-color: var(--color-error-500);
}
.input-wrapper--error:focus-within {
box-shadow: 0 0 0 3px rgb(239 68 68 / 0.15);
}
.input-wrapper--disabled {
background-color: var(--color-gray-100);
cursor: not-allowed;
}
/* Sizes */
.input-wrapper--sm {
height: 32px;
padding: 0 var(--spacing-sm);
}
.input-wrapper--md {
height: 40px;
padding: 0 var(--spacing-md);
}
.input-wrapper--lg {
height: 48px;
padding: 0 var(--spacing-md);
}
.input {
flex: 1;
width: 100%;
border: none;
background: transparent;
font: inherit;
color: var(--color-gray-900);
}
.input:focus {
outline: none;
}
.input::placeholder {
color: var(--color-gray-400);
}
.input:disabled {
cursor: not-allowed;
color: var(--color-gray-500);
}
.input-adornment {
display: flex;
align-items: center;
color: var(--color-gray-500);
}
.input-helper {
font-size: var(--text-sm);
color: var(--color-gray-500);
}
.input-error {
font-size: var(--text-sm);
color: var(--color-error-500);
}
</style>Stack Layout Component
VStack.vue:
<script setup lang="ts">
import { computed, type Component } from 'vue';
type SpacingToken = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
export interface StackProps {
/** HTML element or component to render */
as?: string | Component;
/** Direction of stacking */
direction?: 'row' | 'column';
/** Gap between items */
gap?: SpacingToken;
/** Horizontal alignment */
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
/** Distribution */
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
/** Wrap items */
wrap?: boolean;
/** Full width */
fullWidth?: boolean;
}
const props = withDefaults(defineProps<StackProps>(), {
as: 'div',
direction: 'column',
gap: 'md',
align: 'stretch',
justify: 'start',
wrap: false,
fullWidth: false,
});
const alignMap: Record<string, string> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
stretch: 'stretch',
baseline: 'baseline',
};
const justifyMap: Record<string, string> = {
start: 'flex-start',
center: 'center',
end: 'flex-end',
between: 'space-between',
around: 'space-around',
evenly: 'space-evenly',
};
const styles = computed(() => ({
'--stack-gap': `var(--spacing-${props.gap})`,
'--stack-align': alignMap[props.align],
'--stack-justify': justifyMap[props.justify],
}));
const classes = computed(() => [
'stack',
`stack--${props.direction}`,
{
'stack--wrap': props.wrap,
'stack--full-width': props.fullWidth,
},
]);
</script>
<template>
<component :is="as" :class="classes" :style="styles">
<slot />
</component>
</template>
<style scoped>
.stack {
display: flex;
gap: var(--stack-gap, var(--spacing-md));
align-items: var(--stack-align, stretch);
justify-content: var(--stack-justify, flex-start);
}
.stack--column {
flex-direction: column;
}
.stack--row {
flex-direction: row;
}
.stack--wrap {
flex-wrap: wrap;
}
.stack--full-width {
width: 100%;
}
</style>Composables
useTheme
useTheme.ts:
import { ref, watch, onMounted, readonly } from 'vue';
type Theme = 'light' | 'dark' | 'system';
const theme = ref<Theme>('system');
const resolvedTheme = ref<'light' | 'dark'>('light');
export function useTheme() {
function setTheme(newTheme: Theme) {
theme.value = newTheme;
localStorage.setItem('theme', newTheme);
}
function updateResolvedTheme() {
if (theme.value === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
resolvedTheme.value = prefersDark ? 'dark' : 'light';
} else {
resolvedTheme.value = theme.value;
}
document.documentElement.dataset.theme = resolvedTheme.value;
}
onMounted(() => {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored) theme.value = stored;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateResolvedTheme);
updateResolvedTheme();
});
watch(theme, updateResolvedTheme);
return {
theme: readonly(theme),
resolvedTheme: readonly(resolvedTheme),
setTheme,
};
}useId
useId.ts:
import { getCurrentInstance } from 'vue';
let idCounter = 0;
export function useId(prefix = 'v'): string {
const instance = getCurrentInstance();
const uid = instance?.uid ?? ++idCounter;
return `${prefix}-${uid}`;
}useBreakpoint
useBreakpoint.ts:
import { ref, onMounted, onUnmounted } from 'vue';
const breakpoints = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536,
} as const;
type Breakpoint = keyof typeof breakpoints;
export function useBreakpoint() {
const width = ref(0);
const current = ref<Breakpoint | 'xs'>('xs');
function update() {
width.value = window.innerWidth;
if (width.value >= breakpoints['2xl']) current.value = '2xl';
else if (width.value >= breakpoints.xl) current.value = 'xl';
else if (width.value >= breakpoints.lg) current.value = 'lg';
else if (width.value >= breakpoints.md) current.value = 'md';
else if (width.value >= breakpoints.sm) current.value = 'sm';
else current.value = 'xs';
}
onMounted(() => {
update();
window.addEventListener('resize', update);
});
onUnmounted(() => {
window.removeEventListener('resize', update);
});
const isAbove = (bp: Breakpoint) => width.value >= breakpoints[bp];
const isBelow = (bp: Breakpoint) => width.value < breakpoints[bp];
return {
width,
current,
isAbove,
isBelow,
isMobile: () => isBelow('md'),
isDesktop: () => isAbove('lg'),
};
}Provide/Inject Pattern
ThemeProvider.vue:
<script setup lang="ts">
import { provide, readonly } from 'vue';
import { useTheme } from '@/composables/useTheme';
const { theme, resolvedTheme, setTheme } = useTheme();
provide('theme', {
theme: readonly(theme),
resolvedTheme: readonly(resolvedTheme),
setTheme,
});
</script>
<template>
<slot />
</template>Consuming in child:
<script setup lang="ts">
import { inject } from 'vue';
const themeContext = inject('theme');
</script>v-model Patterns
Multiple v-models:
<script setup lang="ts">
const props = defineProps<{
modelValue: string;
open: boolean;
}>();
const emit = defineEmits<{
'update:modelValue': [value: string];
'update:open': [value: boolean];
}>();
</script>
<!-- Usage -->
<MyComponent v-model="value" v-model:open="isOpen" />Plugin Registration
index.ts:
import type { App, Plugin } from 'vue';
import VButton from './components/primitives/VButton/VButton.vue';
import VInput from './components/primitives/VInput/VInput.vue';
import VStack from './components/layout/VStack/VStack.vue';
import './tokens/index.css';
export { VButton, VInput, VStack };
export const DesignSystem: Plugin = {
install(app: App) {
app.component('VButton', VButton);
app.component('VInput', VInput);
app.component('VStack', VStack);
},
};
export default DesignSystem;Usage:
import { createApp } from 'vue';
import { DesignSystem } from '@my-org/design-system';
import App from './App.vue';
createApp(App).use(DesignSystem).mount('#app');Testing
VButton.test.ts:
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import VButton from './VButton.vue';
describe('VButton', () => {
it('renders slot content', () => {
const wrapper = mount(VButton, {
slots: { default: 'Click me' },
});
expect(wrapper.text()).toContain('Click me');
});
it('emits click event', async () => {
const wrapper = mount(VButton);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toHaveLength(1);
});
it('does not emit when disabled', async () => {
const wrapper = mount(VButton, {
props: { disabled: true },
});
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeUndefined();
});
it('applies variant class', () => {
const wrapper = mount(VButton, {
props: { variant: 'danger' },
});
expect(wrapper.classes()).toContain('btn--danger');
});
it('shows loading state', () => {
const wrapper = mount(VButton, {
props: { loading: true },
});
expect(wrapper.attributes('aria-busy')).toBe('true');
expect(wrapper.find('.btn__spinner').exists()).toBe(true);
});
});Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

