构建组件库
使用您的令牌构建一组十个可复用组件,通过 CVA 应用一致的变体 API 和无障碍标记模式。
构建组件库 是 CoddyKit 上的免费 Tailwind CSS Academy 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Tailwind CSS Academy 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Tailwind CSS Academy 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
Component Library Construction Goals
Building the component library means translating the token and config layer into a set of usable, documented UI primitives. Each component should use the semantic tokens defined in the previous step, expose a typed variant API, meet WCAG AA accessibility standards, and have at least one Storybook story. This lesson walks through building ten representative components using these principles.
Button Component With CVA
The Button is typically the first component in any library. Use class-variance-authority (CVA) to define typed variant and size props. CVA maps prop combinations to class strings, producing clean and predictable class output. The component accepts variant (primary, secondary, ghost, danger) and size (sm, md, lg) props, with full TypeScript inference.
import { cva, type VariantProps } from 'class-variance-authority';
const buttonVariants = cva(
// Base classes shared by all variants
'inline-flex items-center justify-center rounded-button font-semibold transition focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-white hover:bg-primary-hover focus:ring-primary',
secondary: 'bg-surface-elevated text-text-primary border border-border hover:bg-gray-100',
ghost: 'text-primary hover:bg-primary-light',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
},
},
defaultVariants: { variant: 'primary', size: 'md' },
}
);
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
export function Button({ variant, size, className, ...props }: ButtonProps) {
return <button className={buttonVariants({ variant, size, className })} {...props} />;
}Badge Component
The Badge is a small inline label used for status indicators and category tags. It uses CVA with color variants mapped to semantic meaning: default (gray), success (green), warning (yellow), danger (red), info (blue). The base style creates the pill shape common to all variants.
import { cva, type VariantProps } from 'class-variance-authority';
const badgeVariants = cva(
'inline-flex items-center rounded-badge px-2.5 py-0.5 text-xs font-semibold',
{
variants: {
variant: {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
danger: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
},
},
defaultVariants: { variant: 'default' },
}
);
type BadgeProps = React.HTMLAttributes<HTMLSpanElement> &
VariantProps<typeof badgeVariants>;
export function Badge({ variant, className, ...props }: BadgeProps) {
return <span className={badgeVariants({ variant, className })} {...props} />;
}Input Component
The Input component wraps a native input with consistent styling and forwarded refs for form library compatibility. Include a label and optional error message as part of the component's API. The error state changes the border and ring color from gray to red, providing immediate visual feedback without requiring extra CSS.
import { forwardRef } from 'react';
import { clsx } from 'clsx';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, className, id, ...props }, ref) => (
<div className='flex flex-col gap-1'>
{label && (
<label htmlFor={id} className='text-sm font-medium text-text-primary'>
{label}
</label>
)}
<input
ref={ref}
id={id}
aria-invalid={Boolean(error)}
aria-describedby={error ? `${id}-error` : undefined}
className={clsx(
'w-full rounded-input border px-3 py-2 text-sm text-text-primary transition',
'focus:outline-none focus:ring-2 focus:ring-offset-0',
error
? 'border-red-500 focus:ring-red-500'
: 'border-border focus:border-primary focus:ring-primary',
className
)}
{...props}
/>
{error && (
<p id={`${id}-error`} role='alert' className='text-xs text-red-600'>
{error}
</p>
)}
</div>
)
);Card Component
The Card is a container component with optional CardHeader, CardBody, and CardFooter sub-components. Use the compound component pattern — export related pieces from the same file and name them with dot notation or individual exports. The Card uses the shadow-card and bg-surface semantic tokens defined in the config.
// Card.tsx
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx('rounded-card bg-surface shadow-card overflow-hidden', className)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx('border-b border-border px-6 py-4', className)}
{...props}
/>
);
}
export function CardBody({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={clsx('px-6 py-4', className)} {...props} />;
}
export function CardFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx('border-t border-border bg-surface-elevated px-6 py-3', className)}
{...props}
/>
);
}Avatar Component
The Avatar displays a user photo or falls back to initials in a colored circle. Use a size variant for small (8), medium (10), and large (14) sizes. When no image is provided, display initials extracted from the user's name in a deterministic background color keyed to the name's first character. Add a status dot variant for online/offline/busy indicators.
const avatarSize = cva('rounded-full overflow-hidden flex-shrink-0', {
variants: {
size: {
sm: 'h-8 w-8 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-14 w-14 text-base',
},
},
defaultVariants: { size: 'md' },
});
export function Avatar({ src, name, size }: AvatarProps) {
const initials = name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase();
return (
<div className={avatarSize({ size })}>
{src ? (
<img src={src} alt={name} className='h-full w-full object-cover' />
) : (
<div className='flex h-full w-full items-center justify-center
bg-primary-light font-semibold text-primary'>
{initials}
</div>
)}
</div>
);
}Spinner Loading Component
A Spinner communicates loading state. Use Tailwind's animate-spin on an SVG with a transparent track and a colored arc. Size variants match those of the Button component so a Spinner placed inside a loading button renders at the correct size. Include role='status' and a visually hidden label for screen readers.
const spinnerSize = cva('animate-spin', {
variants: {
size: { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-6 w-6' },
},
defaultVariants: { size: 'md' },
});
export function Spinner({ size }: { size?: 'sm' | 'md' | 'lg' }) {
return (
<svg
className={spinnerSize({ size })}
xmlns='http://www.w3.org/2000/svg'
fill='none'
viewBox='0 0 24 24'
role='status'
aria-label='Loading'
>
<circle className='opacity-25' cx='12' cy='12' r='10' stroke='currentColor' strokeWidth='4' />
<path
className='opacity-75'
fill='currentColor'
d='M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z'
/>
</svg>
);
}Tooltip Component
A Tooltip shows supplementary information on hover or focus. The simplest implementation uses CSS-only with group and absolute positioning. The trigger wraps in a group relative container, and the tooltip div uses absolute bottom-full mb-2 hidden group-hover:block to appear above the trigger on hover. Add role='tooltip' and an aria-describedby link for accessibility.
export function Tooltip({ content, children }: TooltipProps) {
return (
<div className='group relative inline-block'>
{children}
<div
role='tooltip'
className='pointer-events-none absolute bottom-full left-1/2 z-10
mb-2 -translate-x-1/2 whitespace-nowrap rounded-lg
bg-gray-900 px-3 py-1.5 text-xs text-white opacity-0
transition-opacity group-hover:opacity-100'
>
{content}
{/* Arrow */}
<div className='absolute left-1/2 top-full -translate-x-1/2
border-4 border-transparent border-t-gray-900' />
</div>
</div>
);
}Toast Notification Component
Toast notifications appear at the corner of the screen and dismiss automatically. Use fixed bottom-4 right-4 z-50 flex flex-col gap-2 for the container that holds multiple toasts. Each toast has a colored left border for the severity variant, an icon, a message, and a close button. Animate entrance and exit with CSS transitions on opacity and translateY.
const toastVariants = cva(
'flex items-start gap-3 rounded-card bg-surface shadow-elevated border-l-4 p-4 min-w-[300px]',
{
variants: {
severity: {
success: 'border-green-500',
error: 'border-red-500',
warning: 'border-yellow-500',
info: 'border-blue-500',
},
},
defaultVariants: { severity: 'info' },
}
);
export function Toast({ severity, message, onClose }: ToastProps) {
return (
<div className={toastVariants({ severity })} role='alert'>
<div className='flex-1'>
<p className='text-sm font-medium text-text-primary'>{message}</p>
</div>
<button onClick={onClose} className='text-text-secondary hover:text-text-primary'>
<span className='sr-only'>Dismiss</span>
×
</button>
</div>
);
}Component Storybook Stories
Each component needs at least one Storybook story per meaningful variant. Stories serve as living documentation and visual regression baselines. Use the CSF3 format (Component Story Format) with named exports for each story. Include a Default, a story per variant, and an AllVariants showcase story that renders all variants side by side for quick comparison.
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
title: 'Primitives/Button',
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {
args: { children: 'Click me', variant: 'primary', size: 'md' },
};
export const Secondary: Story = {
args: { children: 'Cancel', variant: 'secondary' },
};
export const AllVariants: Story = {
render: () => (
<div className='flex flex-wrap gap-4'>
<Button variant='primary'>Primary</Button>
<Button variant='secondary'>Secondary</Button>
<Button variant='ghost'>Ghost</Button>
<Button variant='danger'>Danger</Button>
</div>
),
};Component Unit Testing
Test component behavior — not styling. Focus tests on: correct ARIA attributes are rendered, click handlers fire, disabled state prevents interaction, and error messages appear when the error prop is set. Use @testing-library/react with its accessibility-focused queries like getByRole and getByLabelText rather than querying by class names, which couples tests to styling implementation details.
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
test('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Save</Button>);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('does not call onClick when disabled', () => {
const handleClick = jest.fn();
render(<Button disabled onClick={handleClick}>Save</Button>);
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(handleClick).not.toHaveBeenCalled();
});Quick Check
Test your understanding of Tailwind CSS Mastery concepts from this lesson.
Lesson Recap
In this lesson you learned: building typed components with CVA for Button, Badge, Input, and Card using semantic design tokens, writing accessible components with ARIA attributes, screen reader labels, and keyboard support, and creating Storybook stories and unit tests for each component. Next up is the final lesson: documenting and handing off the design system.
常见问题解答
「构建组件库」课时是免费的吗?
是的 — 「构建组件库」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Tailwind CSS Academy 课程的其余内容,请升级到 CoddyKit PRO。 Tailwind CSS Academy 课程共包含 4 节课。
「构建组件库」这节课中我会学到什么?
使用您的令牌构建一组十个可复用组件,通过 CVA 应用一致的变体 API 和无障碍标记模式。 你通过在浏览器中直接运行的动手代码来练习 Tailwind CSS Academy,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Tailwind CSS Academy 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Tailwind CSS Academy 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。
「构建组件库」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Tailwind CSS Academy 课中编写并运行代码吗?
能。每节 Tailwind CSS Academy 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。