Zum Inhalt springen

Design System entwickeln

Ein Design System ist eine Sammlung wiederverwendbarer Komponenten mit klaren Designregeln. Es sorgt für visuelle Konsistenz und beschleunigt die Entwicklung.

  • Volle Kontrolle über Design und Verhalten
  • Keine Abhängigkeiten von externen Libraries
  • Kleine Bundle-Größe - nur was gebraucht wird
  • Lerneffekt - tiefes Verständnis von Vue
  • Maßgeschneidert für spezifische Anforderungen
src/
├── components/
│ └── ui/ # Design System Komponenten
│ ├── UiButton.vue
│ ├── UiTextField.vue
│ └── index.js # Zentrale Exports
├── styles/
│ ├── tokens.css # Design Tokens (CSS Variables)
│ └── base.css # Reset & Grundstyles
└── main.js

Design Tokens sind die Grundbausteine des Systems: Farben, Abstände, Schriftgrößen, etc.

styles/tokens.css
:root {
/* Farben */
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-primary-active: #1d4ed8;
--color-secondary: #64748b;
--color-secondary-hover: #475569;
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-text-inverse: #ffffff;
--color-background: #ffffff;
--color-surface: #f9fafb;
--color-border: #e5e7eb;
/* Abstände */
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
/* Schriftgrößen */
--font-size-xs: 0.75rem; /* 12px */
--font-size-sm: 0.875rem; /* 14px */
--font-size-md: 1rem; /* 16px */
--font-size-lg: 1.125rem; /* 18px */
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-full: 9999px;
/* Schatten */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
}
styles/base.css
@import './tokens.css';
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
font-size: var(--font-size-md);
color: var(--color-text);
background-color: var(--color-background);
line-height: 1.5;
margin: 0;
}

Ein Button mit verschiedenen Varianten, Größen und Zuständen.

components/ui/UiButton.vue
<script setup>
defineOptions({
name: 'UiButton'
})
const props = defineProps({
/** Button-Variante: primary, secondary, outline, ghost */
variant: {
type: String,
default: 'primary',
validator: (v) => ['primary', 'secondary', 'outline', 'ghost'].includes(v)
},
/** Größe: sm, md, lg */
size: {
type: String,
default: 'md',
validator: (v) => ['sm', 'md', 'lg'].includes(v)
},
/** Deaktiviert */
disabled: {
type: Boolean,
default: false
},
/** Ladezustand */
loading: {
type: Boolean,
default: false
},
/** Button-Typ (submit, button, reset) */
type: {
type: String,
default: 'button'
}
})
const emit = defineEmits(['click'])
function handleClick(event) {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:class="[
'ui-button',
`ui-button--${variant}`,
`ui-button--${size}`,
{
'ui-button--loading': loading,
'ui-button--disabled': disabled
}
]"
@click="handleClick"
>
<span v-if="loading" class="ui-button__spinner" aria-hidden="true" />
<span class="ui-button__content" :class="{ 'ui-button__content--hidden': loading }">
<slot />
</span>
</button>
</template>
<style scoped>
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
font-family: inherit;
font-weight: 500;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
color var(--transition-fast);
position: relative;
}
.ui-button:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Größen */
.ui-button--sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.ui-button--md {
padding: var(--spacing-sm) var(--spacing-md);
font-size: var(--font-size-md);
}
.ui-button--lg {
padding: var(--spacing-md) var(--spacing-lg);
font-size: var(--font-size-lg);
}
/* Varianten */
.ui-button--primary {
background-color: var(--color-primary);
color: var(--color-text-inverse);
}
.ui-button--primary:hover:not(:disabled) {
background-color: var(--color-primary-hover);
}
.ui-button--primary:active:not(:disabled) {
background-color: var(--color-primary-active);
}
.ui-button--secondary {
background-color: var(--color-secondary);
color: var(--color-text-inverse);
}
.ui-button--secondary:hover:not(:disabled) {
background-color: var(--color-secondary-hover);
}
.ui-button--outline {
background-color: transparent;
border-color: var(--color-border);
color: var(--color-text);
}
.ui-button--outline:hover:not(:disabled) {
background-color: var(--color-surface);
border-color: var(--color-primary);
}
.ui-button--ghost {
background-color: transparent;
color: var(--color-primary);
}
.ui-button--ghost:hover:not(:disabled) {
background-color: var(--color-surface);
}
/* Zustände */
.ui-button--disabled,
.ui-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ui-button--loading {
cursor: wait;
}
/* Spinner */
.ui-button__spinner {
position: absolute;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: var(--radius-full);
animation: spin 0.6s linear infinite;
}
.ui-button__content--hidden {
visibility: hidden;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
<script setup>
import { ref } from 'vue'
import UiButton from '@/components/ui/UiButton.vue'
const isLoading = ref(false)
async function handleSubmit() {
isLoading.value = true
await new Promise(r => setTimeout(r, 2000))
isLoading.value = false
}
</script>
<template>
<!-- Varianten -->
<UiButton variant="primary">Primary</UiButton>
<UiButton variant="secondary">Secondary</UiButton>
<UiButton variant="outline">Outline</UiButton>
<UiButton variant="ghost">Ghost</UiButton>
<!-- Größen -->
<UiButton size="sm">Klein</UiButton>
<UiButton size="md">Mittel</UiButton>
<UiButton size="lg">Groß</UiButton>
<!-- Zustände -->
<UiButton disabled>Deaktiviert</UiButton>
<UiButton :loading="isLoading" @click="handleSubmit">
Absenden
</UiButton>
</template>

Ein Textfeld mit Label, Validierung und Hilfetexten.

components/ui/UiTextField.vue
<script setup>
import { computed, useId } from 'vue'
defineOptions({
name: 'UiTextField'
})
const props = defineProps({
/** v-model Binding */
modelValue: {
type: [String, Number],
default: ''
},
/** Label-Text */
label: {
type: String,
default: ''
},
/** Platzhalter */
placeholder: {
type: String,
default: ''
},
/** Input-Typ: text, email, password, number, tel, url */
type: {
type: String,
default: 'text'
},
/** Hilfetext unter dem Input */
hint: {
type: String,
default: ''
},
/** Fehlermeldung (zeigt Error-State) */
error: {
type: String,
default: ''
},
/** Erfolgsmeldung (zeigt Success-State) */
success: {
type: String,
default: ''
},
/** Deaktiviert */
disabled: {
type: Boolean,
default: false
},
/** Pflichtfeld */
required: {
type: Boolean,
default: false
},
/** Nur lesbar */
readonly: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'blur', 'focus'])
// Eindeutige ID für Label-Verknüpfung
const inputId = useId()
// Berechnete Werte
const hasError = computed(() => !!props.error)
const hasSuccess = computed(() => !hasError.value && !!props.success)
const message = computed(() => props.error || props.success || props.hint)
// Event Handler
function onInput(event) {
emit('update:modelValue', event.target.value)
}
function onBlur(event) {
emit('blur', event)
}
function onFocus(event) {
emit('focus', event)
}
</script>
<template>
<div
:class="[
'ui-textfield',
{
'ui-textfield--error': hasError,
'ui-textfield--success': hasSuccess,
'ui-textfield--disabled': disabled
}
]"
>
<!-- Label -->
<label v-if="label" :for="inputId" class="ui-textfield__label">
{{ label }}
<span v-if="required" class="ui-textfield__required" aria-hidden="true">*</span>
</label>
<!-- Input -->
<input
:id="inputId"
:type="type"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:required="required"
:aria-invalid="hasError"
:aria-describedby="message ? `${inputId}-message` : undefined"
class="ui-textfield__input"
@input="onInput"
@blur="onBlur"
@focus="onFocus"
/>
<!-- Hilfetext / Fehlermeldung -->
<p
v-if="message"
:id="`${inputId}-message`"
class="ui-textfield__message"
>
{{ message }}
</p>
</div>
</template>
<style scoped>
.ui-textfield {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
/* Label */
.ui-textfield__label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.ui-textfield__required {
color: var(--color-error);
margin-left: 2px;
}
/* Input */
.ui-textfield__input {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
font-family: inherit;
font-size: var(--font-size-md);
color: var(--color-text);
background-color: var(--color-background);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.ui-textfield__input::placeholder {
color: var(--color-text-muted);
}
.ui-textfield__input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.ui-textfield__input:disabled {
background-color: var(--color-surface);
cursor: not-allowed;
opacity: 0.7;
}
.ui-textfield__input:read-only {
background-color: var(--color-surface);
}
/* Error State */
.ui-textfield--error .ui-textfield__input {
border-color: var(--color-error);
}
.ui-textfield--error .ui-textfield__input:focus {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}
/* Success State */
.ui-textfield--success .ui-textfield__input {
border-color: var(--color-success);
}
.ui-textfield--success .ui-textfield__input:focus {
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15);
}
/* Message */
.ui-textfield__message {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0;
}
.ui-textfield--error .ui-textfield__message {
color: var(--color-error);
}
.ui-textfield--success .ui-textfield__message {
color: var(--color-success);
}
</style>
<script setup>
import { ref, computed } from 'vue'
import UiTextField from '@/components/ui/UiTextField.vue'
const email = ref('')
const password = ref('')
const touched = ref({ email: false, password: false })
// Validierung
const emailError = computed(() => {
if (!touched.value.email) return ''
if (!email.value) return 'E-Mail ist erforderlich'
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)) {
return 'Ungültige E-Mail-Adresse'
}
return ''
})
const passwordError = computed(() => {
if (!touched.value.password) return ''
if (!password.value) return 'Passwort ist erforderlich'
if (password.value.length < 8) {
return 'Mindestens 8 Zeichen erforderlich'
}
return ''
})
const emailSuccess = computed(() => {
if (!touched.value.email || emailError.value) return ''
return 'E-Mail ist gültig'
})
</script>
<template>
<form @submit.prevent>
<UiTextField
v-model="email"
label="E-Mail"
type="email"
placeholder="max@beispiel.de"
hint="Ihre geschäftliche E-Mail-Adresse"
:error="emailError"
:success="emailSuccess"
required
@blur="touched.email = true"
/>
<UiTextField
v-model="password"
label="Passwort"
type="password"
placeholder="••••••••"
:error="passwordError"
required
@blur="touched.password = true"
/>
<UiTextField
model-value="Nur lesbar"
label="Status"
readonly
/>
<UiTextField
label="Deaktiviert"
placeholder="Nicht verfügbar"
disabled
/>
</form>
</template>
components/ui/index.js
export { default as UiButton } from './UiButton.vue'
export { default as UiTextField } from './UiTextField.vue'
<!-- Einfacher Import in Komponenten -->
<script setup>
import { UiButton, UiTextField } from '@/components/ui'
</script>
main.js
import { createApp } from 'vue'
import App from './App.vue'
// Design System importieren
import { UiButton, UiTextField } from '@/components/ui'
import '@/styles/base.css'
const app = createApp(App)
// Global registrieren (ohne Import in jeder Komponente)
app.component('UiButton', UiButton)
app.component('UiTextField', UiTextField)
app.mount('#app')
<script setup>
import { ref, computed } from 'vue'
import { UiButton, UiTextField } from '@/components/ui'
const form = ref({
email: '',
password: ''
})
const touched = ref({
email: false,
password: false
})
const isSubmitting = ref(false)
// Validierung
const errors = computed(() => ({
email: touched.value.email && !form.value.email
? 'E-Mail ist erforderlich'
: '',
password: touched.value.password && !form.value.password
? 'Passwort ist erforderlich'
: ''
}))
const isValid = computed(() =>
form.value.email && form.value.password
)
async function handleSubmit() {
// Alle Felder als touched markieren
touched.value = { email: true, password: true }
if (!isValid.value) return
isSubmitting.value = true
try {
// API-Call simulieren
await new Promise(r => setTimeout(r, 1500))
console.log('Login erfolgreich:', form.value)
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<form class="login-form" @submit.prevent="handleSubmit">
<h2>Anmelden</h2>
<UiTextField
v-model="form.email"
label="E-Mail"
type="email"
placeholder="max@beispiel.de"
:error="errors.email"
required
@blur="touched.email = true"
/>
<UiTextField
v-model="form.password"
label="Passwort"
type="password"
placeholder="••••••••"
:error="errors.password"
required
@blur="touched.password = true"
/>
<div class="login-form__actions">
<UiButton
type="submit"
variant="primary"
:loading="isSubmitting"
:disabled="!isValid"
>
Anmelden
</UiButton>
<UiButton variant="ghost" type="button">
Passwort vergessen?
</UiButton>
</div>
</form>
</template>
<style scoped>
.login-form {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
max-width: 400px;
padding: var(--spacing-xl);
background: var(--color-background);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.login-form h2 {
margin: 0 0 var(--spacing-sm);
}
.login-form__actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--spacing-sm);
}
</style>
/* Dark Theme */
[data-theme="dark"] {
--color-primary: #60a5fa;
--color-primary-hover: #93c5fd;
--color-text: #f3f4f6;
--color-text-muted: #9ca3af;
--color-text-inverse: #111827;
--color-background: #111827;
--color-surface: #1f2937;
--color-border: #374151;
}
<script setup>
import { ref } from 'vue'
const isDark = ref(false)
function toggleTheme() {
isDark.value = !isDark.value
document.documentElement.setAttribute(
'data-theme',
isDark.value ? 'dark' : 'light'
)
}
</script>
<template>
<UiButton @click="toggleTheme">
{{ isDark ? '☀️ Light' : '🌙 Dark' }}
</UiButton>
</template>

Nach Button und TextField können weitere Komponenten folgen:

KomponenteBeschreibung
UiCheckboxCheckbox mit Label
UiRadioRadio-Button Gruppe
UiSelectDropdown-Auswahl
UiCardContainer mit Schatten
UiModalDialog/Overlay
UiAlertHinweis-Banner
UiSpinnerLadeanzeige
UiTooltipHover-Hinweise
  • Design Tokens für alle Werte verwenden
  • Props validieren mit validator
  • Accessibility beachten (Labels, ARIA)
  • Konsistente Benennung (Ui-Prefix)
  • Scoped Styles für Isolation
  • Sinnvolle Defaults setzen
  • Hardcoded Farben/Größen
  • Fehlende Keyboard-Navigation
  • Zu viele Props (lieber Slots)
  • Styles ohne Prefix (Kollisionen)
  • Komponenten zu spezifisch machen
  • Einzigartiges Design erforderlich
  • Wenige Komponenten nötig
  • Volle Kontrolle wichtig
  • Lernprojekt
  • Bundle-Größe kritisch

Externe Ressourcen: