Design System entwickeln
Ein Design System ist eine Sammlung wiederverwendbarer Komponenten mit klaren Designregeln. Es sorgt für visuelle Konsistenz und beschleunigt die Entwicklung.
Warum ein eigenes Design System?
Abschnitt betitelt „Warum ein eigenes Design System?“- 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
- Zeitaufwand für Entwicklung
- Accessibility selbst implementieren
- Keine Community-Unterstützung
- Wartung in eigener Verantwortung
Projektstruktur
Abschnitt betitelt „Projektstruktur“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.jsDesign Tokens
Abschnitt betitelt „Design Tokens“Design Tokens sind die Grundbausteine des Systems: Farben, Abstände, Schriftgrößen, etc.
: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;}@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;}Komponente 1: UiButton
Abschnitt betitelt „Komponente 1: UiButton“Ein Button mit verschiedenen Varianten, Größen und Zuständen.
Props definieren
Abschnitt betitelt „Props definieren“<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>Button verwenden
Abschnitt betitelt „Button verwenden“<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>Komponente 2: UiTextField
Abschnitt betitelt „Komponente 2: UiTextField“Ein Textfeld mit Label, Validierung und Hilfetexten.
<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üpfungconst inputId = useId()
// Berechnete Werteconst hasError = computed(() => !!props.error)const hasSuccess = computed(() => !hasError.value && !!props.success)const message = computed(() => props.error || props.success || props.hint)
// Event Handlerfunction 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>TextField verwenden
Abschnitt betitelt „TextField verwenden“<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 })
// Validierungconst 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>Komponenten exportieren
Abschnitt betitelt „Komponenten exportieren“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>Globale Registrierung (Optional)
Abschnitt betitelt „Globale Registrierung (Optional)“import { createApp } from 'vue'import App from './App.vue'
// Design System importierenimport { 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')Praxisbeispiel: Login-Formular
Abschnitt betitelt „Praxisbeispiel: Login-Formular“<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)
// Validierungconst 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>Theming mit CSS Variables
Abschnitt betitelt „Theming mit CSS Variables“/* 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>Erweiterungsideen
Abschnitt betitelt „Erweiterungsideen“Nach Button und TextField können weitere Komponenten folgen:
| Komponente | Beschreibung |
|---|---|
| UiCheckbox | Checkbox mit Label |
| UiRadio | Radio-Button Gruppe |
| UiSelect | Dropdown-Auswahl |
| UiCard | Container mit Schatten |
| UiModal | Dialog/Overlay |
| UiAlert | Hinweis-Banner |
| UiSpinner | Ladeanzeige |
| UiTooltip | Hover-Hinweise |
Best Practices
Abschnitt betitelt „Best Practices“- 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
Wann eine UI-Library nutzen?
Abschnitt betitelt „Wann eine UI-Library nutzen?“- Einzigartiges Design erforderlich
- Wenige Komponenten nötig
- Volle Kontrolle wichtig
- Lernprojekt
- Bundle-Größe kritisch
- Schnelle Entwicklung
- Viele Komponenten nötig
- Accessibility out-of-the-box
- Team ohne UI-Expertise
- Standard-Design akzeptabel
Weiterführend
Abschnitt betitelt „Weiterführend“- Props - Props und Validierung
- Slots - Flexible Inhalte
- v-bind() in CSS - Reaktive Styles
- Class & Style Binding - Dynamische Klassen
Externe Ressourcen:
- Headless UI - Unstyled Components
- Radix Vue - Accessible Primitives
- VueUse - Composition Utilities