From frontend-developer
Frontend component design — props API, slots, composability, atomic design, avoiding prop drilling, and component boundary decisions.
How this skill is triggered — by the user, by Claude, or both
Slash command
/frontend-developer:component-designThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Before writing a component, answer: **what is this component's single responsibility?**
Before writing a component, answer: what is this component's single responsibility?
A component should do one thing well. If you need "and" to describe it, split it.
Atoms → AppButton, AppInput, AppBadge, AppIcon
Molecules → SearchField (Input + Button), FormField (Label + Input + Error)
Organisms → NavigationBar, ProductCard, UserProfileHeader
Templates → DashboardLayout, AuthLayout
Pages → DashboardPage, LoginPage
Rules:
// Bad: loose, implicit API
interface Props {
config: Record<string, any>
data: any[]
options?: any
}
// Good: explicit, typed, minimal surface area
interface Props {
items: Product[]
selectedId?: string
loading?: boolean
emptyLabel?: string
onSelect?: (id: string) => void
}
// Bad: boolean explosion
interface Props {
isPrimary?: boolean
isDanger?: boolean
isOutline?: boolean
isSmall?: boolean
isLarge?: boolean
}
// Good: enum-style union
interface Props {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
// Vue
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
loading: false,
disabled: false,
})
// React
function Button({ variant = 'primary', size = 'md', loading = false, ...rest }: Props) {}
// Bad: component requires full user object but only uses name + avatar
interface Props {
user: User // User has 20 fields
}
// Good: only what you need
interface Props {
userName: string
userAvatarUrl: string
}
Slots are the primary extension point. Prefer them over complex conditional rendering.
<!-- DataTable.vue — flexible, composable -->
<template>
<table>
<thead>
<slot name="header" :columns="columns" />
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<slot name="row" :row="row" :index="index" />
</tr>
</tbody>
<tfoot>
<slot name="footer" :total="rows.length">
<!-- Default footer -->
<tr><td>{{ rows.length }} items</td></tr>
</slot>
</tfoot>
</table>
</template>
<!-- Usage: parent controls rendering -->
<DataTable :rows="products" :columns="columns">
<template #header="{ columns }">
<th v-for="col in columns" :key="col.key" @click="sortBy(col.key)">
{{ col.label }}
</th>
</template>
<template #row="{ row }">
<td>{{ row.name }}</td>
<td><AppBadge :variant="row.status" /></td>
</template>
</DataTable>
// List.tsx — accepts render function for item
interface Props<T> {
items: T[]
keyExtractor: (item: T) => string
renderItem: (item: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function List<T>({ items, keyExtractor, renderItem, renderEmpty }: Props<T>) {
if (items.length === 0) {
return renderEmpty?.() ?? <p>No items.</p>
}
return (
<ul>
{items.map((item, i) => (
<li key={keyExtractor(item)}>{renderItem(item, i)}</li>
))}
</ul>
)
}
// Usage
<List
items={products}
keyExtractor={(p) => p.id}
renderItem={(product) => <ProductCard product={product} />}
renderEmpty={() => <EmptyState message="No products found" />}
/>
| Depth | Solution |
|---|---|
| 1-2 levels | Pass props directly |
| 2-3 levels, rarely changes | Slot/render prop to skip intermediate |
| 3+ levels, shared across subtree | provide/inject (Vue) or Context (React) |
| Global, changes frequently | Pinia store / Zustand |
provide/inject with type safety// tokens.ts — define injection keys
import type { InjectionKey, Ref } from 'vue'
export interface TableContext {
selectedRows: Ref<Set<string>>
toggleRow: (id: string) => void
isSelected: (id: string) => boolean
}
export const TableContextKey: InjectionKey<TableContext> = Symbol('TableContext')
// DataTable.vue — provide context to all descendants
import { TableContextKey } from './tokens'
const selectedRows = ref<Set<string>>(new Set())
provide(TableContextKey, {
selectedRows,
toggleRow: (id) => {
if (selectedRows.value.has(id)) selectedRows.value.delete(id)
else selectedRows.value.add(id)
},
isSelected: (id) => selectedRows.value.has(id),
})
// TableRow.vue — consume context without props
const { isSelected, toggleRow } = inject(TableContextKey)!
Split when:
Don't split just because it looks big. Premature decomposition creates indirection without value.
One Form.vue at 300 lines
↓ don't split into
NameSection.vue + AddressSection.vue + PaymentSection.vue
(unless those sections are genuinely reusable elsewhere)
ParentPage
↓ props (data down)
ProductList
↓ props
ProductCard
↑ emits('add-to-cart', id) (events up)
↑ handles add-to-cart
// Bad
const props = defineProps<{ count: number }>()
props.count++ // ← mutates parent's state silently
// Good: emit the intent, let parent decide
const emit = defineEmits<{ 'update:count': [value: number] }>()
function increment() {
emit('update:count', props.count + 1)
}
For complex, tightly-coupled UI (tabs, accordion, select), use the compound pattern:
<!-- Usage: natural, readable, flexible -->
<Tabs v-model="activeTab">
<TabList>
<Tab value="profile">Profile</Tab>
<Tab value="security">Security</Tab>
<Tab value="billing" :disabled="!isPremium">Billing</Tab>
</TabList>
<TabPanel value="profile"><ProfileForm /></TabPanel>
<TabPanel value="security"><SecuritySettings /></TabPanel>
<TabPanel value="billing"><BillingSettings /></TabPanel>
</Tabs>
Implementation uses provide/inject to share state between Tabs, Tab, and TabPanel without exposing it externally.
Separate logic from presentation for maximum reusability:
// composables/useDropdown.ts — pure logic, no template
export function useDropdown() {
const isOpen = ref(false)
const selectedValue = ref<string | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
function open() { isOpen.value = true }
function close() { isOpen.value = false }
function toggle() { isOpen.value = !isOpen.value }
function select(value: string) {
selectedValue.value = value
close()
}
// Keyboard handling
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close()
if (e.key === 'Enter' || e.key === ' ') toggle()
}
return { isOpen, selectedValue, triggerRef, open, close, toggle, select, handleKeydown }
}
// DesignSystem/Dropdown.vue — branded, styled
// ProjectCustomDropdown.vue — project-specific style
// Both use the same useDropdown() composable
config={{ mode: 'edit', showFooter: true, ... }} hides the API; each configurable aspect should be its own prop<Button primary large outline round /> — multiple boolean props that can contradict each other; use variant and size unions insteadprovide/inject, a store, or restructure the treev-model props emit wrong event name (change instead of update:modelValue); breaks two-way bindingnpx claudepluginhub messeb/skills --plugin frontend-developerDecomposes components into slots, variants, states, and sizes; guides decisions on composition vs configuration, prop sprawl, and splitting or merging components. Useful when designing APIs or structuring design systems.
Designs reusable UI components in React, Vue, Svelte using composition patterns, CSS-in-JS like Tailwind/styled-components, and API best practices for libraries and design systems.
Guides the design of props interfaces, composition models, and public APIs for reusable React/UI components using atomic design patterns and inversion of control. Helps avoid broken contracts and escape hatches.