TailwindCSS打造优雅的对话UI组件
前言
TailwindCSS的原子化CSS理念让UI开发变得高效而优雅。本文将详细介绍如何使用TailwindCSS构建现代化的AI对话界面组件。
适合读者: 前端开发者、UI工程师、设计师
一、TailwindCSS配置
1.1 安装和初始化
# 安装TailwindCSS
npm install -D tailwindcss postcss autoprefixer
# 初始化配置
npx tailwindcss init -p
1.2 自定义配置
// tailwind.config.ts
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
// 自定义颜色系统
gray: {
1: '#ffffff',
2: '#fafafa',
3: '#f5f5f5',
4: '#e5e5e5',
5: '#d4d4d4',
6: '#a3a3a3',
7: '#737373',
8: '#525252',
9: '#404040',
10: '#262626',
},
blue: {
1: '#eff6ff',
2: '#dbeafe',
3: '#bfdbfe',
4: '#93c5fd',
5: '#60a5fa',
6: '#3b82f6',
7: '#2563eb',
8: '#1d4ed8',
9: '#1e40af',
10: '#1e3a8a',
},
},
animation: {
'bounce-slow': 'bounce 1.5s infinite',
'pulse-slow': 'pulse 2s infinite',
},
},
},
plugins: [],
}
export default config
1.3 全局样式
/* app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply bg-gray-2 text-gray-10;
}
}
@layer components {
/* 自定义组件样式 */
.btn-primary {
@apply px-4 py-2 bg-blue-6 text-white rounded-lg hover:bg-blue-7 transition-colors;
}
.input-base {
@apply px-3 py-2 border border-gray-4 rounded-lg focus:outline-none focus:border-blue-6 transition-colors;
}
}
二、消息气泡组件
2.1 基础消息气泡
// components/MessageBubble.tsx
interface MessageBubbleProps {
content: string
isUser: boolean
timestamp?: string
}
export function MessageBubble({ content, isUser, timestamp }: MessageBubbleProps) {
return (
<div className={`mb-6 flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`flex space-x-3 max-w-[80%] ${
isUser ? 'flex-row-reverse space-x-reverse' : ''
}`}
>
{/* 头像 */}
<div
className={`
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
${isUser ? 'bg-blue-6' : 'bg-gray-4'}
`}
>
{isUser ? (
<span className="text-white text-sm">👤</span>
) : (
<span className="text-gray-7 text-sm">🤖</span>
)}
</div>
{/* 消息内容 */}
<div className="flex flex-col space-y-1">
<div
className={`
px-4 py-3 rounded-2xl
${
isUser
? 'bg-blue-6 text-white rounded-br-md'
: 'bg-white border border-gray-4 text-gray-10 rounded-bl-md'
}
`}
>
<p className="text-sm whitespace-pre-wrap break-words">
{content}
</p>
</div>
{/* 时间戳 */}
{timestamp && (
<span className={`text-xs text-gray-6 ${isUser ? 'text-right' : 'text-left'}`}>
{new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})}
</span>
)}
</div>
</div>
</div>
)
}
2.2 带动画的消息气泡
// components/AnimatedMessageBubble.tsx
'use client'
import { motion } from 'framer-motion'
export function AnimatedMessageBubble({ content, isUser }: MessageBubbleProps) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className={`mb-6 flex ${isUser ? 'justify-end' : 'justify-start'}`}
>
<div
className={`flex space-x-3 max-w-[80%] ${
isUser ? 'flex-row-reverse space-x-reverse' : ''
}`}
>
{/* 头像动画 */}
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
className={`
w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0
${isUser ? 'bg-blue-6' : 'bg-gray-4'}
`}
>
{isUser ? '👤' : '🤖'}
</motion.div>
{/* 消息内容动画 */}
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2 }}
className={`
px-4 py-3 rounded-2xl
${
isUser
? 'bg-blue-6 text-white'
: 'bg-white border border-gray-4 text-gray-10'
}
`}
>
<p className="text-sm whitespace-pre-wrap break-words">
{content}
</p>
</motion.div>
</div>
</motion.div>
)
}
2.3 Markdown渲染支持
// components/MarkdownMessage.tsx
import ReactMarkdown from 'react-markdown'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/cjs/styles/prism'
export function MarkdownMessage({ content, isUser }: MessageBubbleProps) {
return (
<div className={`mb-6 flex ${isUser ? 'justify-end' : 'justify-start'}`}>
<div
className={`
px-4 py-3 rounded-2xl max-w-[80%]
${
isUser
? 'bg-blue-6 text-white'
: 'bg-white border border-gray-4 text-gray-10'
}
`}
>
<ReactMarkdown
className="prose prose-sm max-w-none"
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
return !inline && match ? (
<SyntaxHighlighter
style={vscDarkPlus}
language={match[1]}
PreTag="div"
{...props}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className="bg-gray-2 px-1 py-0.5 rounded text-xs" {...props}>
{children}
</code>
)
}
}}
>
{content}
</ReactMarkdown>
</div>
</div>
)
}
三、输入框组件
3.1 基础输入框
// components/ChatInput.tsx
import { useState, useRef, useEffect } from 'react'
interface ChatInputProps {
onSend: (message: string) => void
disabled?: boolean
placeholder?: string
}
export function ChatInput({ onSend, disabled, placeholder }: ChatInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
// 自动调整高度
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'
}
}, [value])
const handleSend = () => {
if (value.trim() && !disabled) {
onSend(value)
setValue('')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
return (
<div className="max-w-3xl mx-auto">
<div className="flex items-end space-x-3 bg-gray-2 rounded-2xl p-3 border border-gray-4 focus-within:border-blue-6 transition-colors">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder || '在这里输入消息...'}
disabled={disabled}
rows={1}
className="
flex-1 bg-transparent resize-none outline-none
text-sm text-gray-10 placeholder-gray-6
max-h-32 overflow-y-auto
disabled:opacity-50 disabled:cursor-not-allowed
"
/>
<button
onClick={handleSend}
disabled={!value.trim() || disabled}
className="
p-2.5 bg-blue-6 text-white rounded-xl
hover:bg-blue-7
disabled:opacity-50 disabled:cursor-not-allowed
transition-colors flex-shrink-0
"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</div>
<p className="text-xs text-gray-6 text-center mt-2">
按 Enter 发送,Shift + Enter 换行
</p>
</div>
)
}
3.2 带附件上传的输入框
// components/AdvancedChatInput.tsx
import { useState, useRef } from 'react'
export function AdvancedChatInput({ onSend, disabled }: ChatInputProps) {
const [value, setValue] = useState('')
const [files, setFiles] = useState<File[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files))
}
}
const removeFile = (index: number) => {
setFiles(files.filter((_, i) => i !== index))
}
return (
<div className="max-w-3xl mx-auto">
{/* 文件预览 */}
{files.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center space-x-2 px-3 py-2 bg-gray-2 rounded-lg border border-gray-4"
>
<span className="text-sm text-gray-10 truncate max-w-[200px]">
{file.name}
</span>
<button
onClick={() => removeFile(index)}
className="text-gray-6 hover:text-red-6"
>
✕
</button>
</div>
))}
</div>
)}
{/* 输入框 */}
<div className="flex items-end space-x-3 bg-gray-2 rounded-2xl p-3 border border-gray-4 focus-within:border-blue-6">
{/* 附件按钮 */}
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 text-gray-6 hover:text-blue-6 transition-colors"
>
📎
</button>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
/>
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="在这里输入消息..."
disabled={disabled}
rows={1}
className="flex-1 bg-transparent resize-none outline-none text-sm text-gray-10 placeholder-gray-6 max-h-32"
/>
<button
onClick={() => onSend(value)}
disabled={!value.trim() || disabled}
className="p-2.5 bg-blue-6 text-white rounded-xl hover:bg-blue-7 disabled:opacity-50 transition-colors"
>
➤
</button>
</div>
</div>
)
}
四、加载和状态组件
4.1 思考指示器
// components/ThinkingIndicator.tsx
export function ThinkingIndicator() {
return (
<div className="mb-6 flex justify-start">
<div className="flex space-x-3 max-w-[80%]">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-gray-4">
<span className="text-sm">🤖</span>
</div>
<div className="px-4 py-3 rounded-2xl bg-white border border-gray-4">
<div className="flex space-x-2">
<div
className="w-2 h-2 bg-gray-5 rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<div
className="w-2 h-2 bg-gray-5 rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<div
className="w-2 h-2 bg-gray-5 rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</div>
</div>
</div>
</div>
)
}
4.2 骨架屏
// components/MessageSkeleton.tsx
export function MessageSkeleton() {
return (
<div className="mb-6 flex justify-start">
<div className="flex space-x-3 max-w-[80%]">
{/* 头像骨架 */}
<div className="w-8 h-8 rounded-full bg-gray-3 animate-pulse" />
{/* 消息骨架 */}
<div className="space-y-2">
<div className="h-4 w-48 bg-gray-3 rounded animate-pulse" />
<div className="h-4 w-32 bg-gray-3 rounded animate-pulse" />
</div>
</div>
</div>
)
}
4.3 空状态
// components/EmptyState.tsx
export function EmptyState() {
return (
<div className="h-full flex flex-col items-center justify-center text-center px-4">
<div className="w-16 h-16 bg-blue-1 rounded-2xl flex items-center justify-center mb-4">
<span className="text-3xl">💬</span>
</div>
<h2 className="text-2xl font-semibold text-gray-10 mb-2">
今天有什么可以帮到你?
</h2>
<p className="text-gray-6 mb-6 max-w-md">
点击"新建对话"开始与 AI 助手交流,或选择左侧的历史对话继续聊天
</p>
<button className="flex items-center space-x-2 px-6 py-3 bg-blue-6 text-white rounded-lg hover:bg-blue-7 transition-colors">
<span>✨</span>
<span>新建对话</span>
</button>
</div>
)
}
五、侧边栏组件
5.1 对话列表
// components/ConversationList.tsx
interface Conversation {
id: string
title: string
updated_at: string
}
interface ConversationListProps {
conversations: Conversation[]
currentId?: string
onSelect: (id: string) => void
onDelete: (id: string) => void
}
export function ConversationList({
conversations,
currentId,
onSelect,
onDelete
}: ConversationListProps) {
return (
<div className="flex-1 overflow-y-auto p-2">
{conversations.length === 0 ? (
<div className="text-center text-gray-6 text-sm py-8">
暂无对话记录
</div>
) : (
conversations.map((conv) => (
<div
key={conv.id}
className={`
group relative px-3 py-2.5 mb-1 rounded-lg cursor-pointer transition-colors
${
currentId === conv.id
? 'bg-blue-1 text-blue-7'
: 'hover:bg-gray-2 text-gray-10'
}
`}
onClick={() => onSelect(conv.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-sm">💬</span>
<span className="text-sm truncate">{conv.title}</span>
</div>
{/* 删除按钮 */}
<button
onClick={(e) => {
e.stopPropagation()
onDelete(conv.id)
}}
className="
opacity-0 group-hover:opacity-100
p-1 hover:bg-red-1 rounded
transition-opacity
"
>
<span className="text-red-6 text-xs">🗑️</span>
</button>
</div>
{/* 时间戳 */}
<div className="text-xs text-gray-6 mt-1">
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
</div>
</div>
))
)}
</div>
)
}
5.2 可折叠侧边栏
// components/Sidebar.tsx
'use client'
import { useState } from 'react'
export function Sidebar({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(true)
return (
<>
{/* 侧边栏 */}
<div
className={`
${isOpen ? 'w-64' : 'w-0'}
transition-all duration-300
bg-white border-r border-gray-4
flex flex-col overflow-hidden
`}
>
{children}
</div>
{/* 切换按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className="
fixed left-4 top-4 z-50
lg:hidden
p-2 bg-white border border-gray-4 rounded-lg
hover:bg-gray-2 transition-colors
"
>
{isOpen ? '✕' : '☰'}
</button>
</>
)
}
六、通知和提示组件
6.1 Toast通知
// components/Toast.tsx
'use client'
import { createContext, useContext, useState } from 'react'
type ToastType = 'success' | 'error' | 'info' | 'warning'
interface Toast {
id: string
type: ToastType
message: string
}
const ToastContext = createContext<{
showToast: (type: ToastType, message: string) => void
} | null>(null)
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const showToast = (type: ToastType, message: string) => {
const id = Date.now().toString()
setToasts(prev => [...prev, { id, type, message }])
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 3000)
}
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast容器 */}
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`
px-4 py-3 rounded-lg shadow-lg
animate-in slide-in-from-right
${
toast.type === 'success' ? 'bg-green-6 text-white' :
toast.type === 'error' ? 'bg-red-6 text-white' :
toast.type === 'warning' ? 'bg-yellow-6 text-white' :
'bg-blue-6 text-white'
}
`}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const context = useContext(ToastContext)
if (!context) throw new Error('useToast must be used within ToastProvider')
return context
}
6.2 确认对话框
// components/ConfirmDialog.tsx
interface ConfirmDialogProps {
open: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
message,
onConfirm,
onCancel
}: ConfirmDialogProps) {
if (!open) return null
return (
<div
className="fixed inset-0 flex items-center justify-center z-50 bg-black/50"
onClick={onCancel}
>
<div
className="bg-white rounded-2xl p-6 max-w-sm w-full mx-4 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-gray-10 mb-2">
{title}
</h3>
<p className="text-gray-7 text-sm mb-6">
{message}
</p>
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 text-gray-10 hover:bg-gray-2 rounded-lg transition-colors"
>
取消
</button>
<button
onClick={onConfirm}
className="px-4 py-2 bg-red-6 text-white rounded-lg hover:bg-red-7 transition-colors"
>
确认
</button>
</div>
</div>
</div>
)
}
七、响应式设计技巧
7.1 断点系统
// Tailwind默认断点
const breakpoints = {
sm: '640px', // 手机横屏
md: '768px', // 平板
lg: '1024px', // 笔记本
xl: '1280px', // 桌面
'2xl': '1536px' // 大屏
}
// 使用示例
<div className="
w-full /* 默认全宽 */
sm:w-1/2 /* 640px以上半宽 */
md:w-1/3 /* 768px以上1/3宽 */
lg:w-1/4 /* 1024px以上1/4宽 */
">
响应式内容
</div>
7.2 移动端优化
// components/MobileOptimized.tsx
export function MobileOptimized() {
return (
<div className="
p-4 /* 移动端padding */
md:p-6 /* 平板padding */
lg:p-8 /* 桌面padding */
text-sm /* 移动端字体 */
md:text-base /* 平板字体 */
space-y-4 /* 移动端间距 */
md:space-y-6 /* 平板间距 */
">
<h1 className="
text-xl /* 移动端标题 */
md:text-2xl /* 平板标题 */
lg:text-3xl /* 桌面标题 */
">
响应式标题
</h1>
</div>
)
}
八、暗黑模式支持
8.1 配置暗黑模式
// tailwind.config.ts
export default {
darkMode: 'class', // 使用class策略
// ...
}
8.2 暗黑模式组件
// components/DarkModeToggle.tsx
'use client'
import { useEffect, useState } from 'react'
export function DarkModeToggle() {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
const isDarkMode = localStorage.getItem('darkMode') === 'true'
setIsDark(isDarkMode)
document.documentElement.classList.toggle('dark', isDarkMode)
}, [])
const toggle = () => {
const newValue = !isDark
setIsDark(newValue)
localStorage.setItem('darkMode', String(newValue))
document.documentElement.classList.toggle('dark', newValue)
}
return (
<button
onClick={toggle}
className="p-2 rounded-lg hover:bg-gray-2 dark:hover:bg-gray-8 transition-colors"
>
{isDark ? '🌙' : '☀️'}
</button>
)
}
8.3 暗黑模式样式
// 使用dark:前缀
<div className="
bg-white dark:bg-gray-9
text-gray-10 dark:text-gray-1
border-gray-4 dark:border-gray-7
">
支持暗黑模式的内容
</div>
九、性能优化
9.1 PurgeCSS优化
// tailwind.config.ts
export default {
content: [
'./app/**/*.{js,ts,jsx,tsx}',
'./components/**/*.{js,ts,jsx,tsx}',
],
// 自动移除未使用的CSS
}
9.2 JIT模式
// Tailwind 3.0+ 默认启用JIT
// 按需生成CSS,大幅减小文件体积
十、总结
TailwindCSS构建UI组件的核心要点:
✅ 原子化CSS - 快速构建UI
✅ 响应式设计 - 移动端优先
✅ 暗黑模式 - 提升用户体验
✅ 自定义配置 - 符合设计系统
✅ 性能优化 - JIT模式按需生成
下一篇预告: 《TypeScript类型安全:前端与后端的契约设计》
作者简介: 资深开发者,创业者。专注于视频通讯技术领域。国内首本Flutter著作《Flutter技术入门与实战》作者,另著有《Dart语言实战》及《WebRTC音视频开发》等书籍。多年从事视频会议、远程教育等技术研发,对于Android、iOS以及跨平台开发技术有比较深入的研究和应用,作为主要程序员开发了多个应用项目,涉及医疗、交通、银行等领域。
学习资料:
欢迎交流: 如有问题欢迎在评论区讨论 🚀