Appearance
CSS Transitions过渡
概述
CSS Transitions为网页元素的状态变化提供平滑的过渡效果,是实现简单动画的首选方案。本文将深入讲解CSS过渡的原理、属性配置,以及在React应用中的最佳实践,帮助你创建流畅的用户交互体验。
CSS Transitions基础
过渡原理
CSS Transitions通过在元素的初始状态和最终状态之间插值来创建动画效果。
css
/* 基本语法 */
.element {
transition: property duration timing-function delay;
}
/* 示例 */
.button {
background-color: #3b82f6;
transition: background-color 0.3s ease-in-out;
}
.button:hover {
background-color: #2563eb;
}核心属性详解
1. transition-property
指定要过渡的CSS属性。
css
/* 单个属性 */
.box {
transition-property: width;
}
/* 多个属性 */
.card {
transition-property: transform, opacity, box-shadow;
}
/* 所有属性 */
.element {
transition-property: all;
}
/* 注意: all可能影响性能,建议明确指定属性 */2. transition-duration
设置过渡持续时间。
css
/* 秒单位 */
.fast {
transition-duration: 0.3s;
}
/* 毫秒单位 */
.slow {
transition-duration: 500ms;
}
/* 多属性不同时长 */
.complex {
transition-property: width, height, opacity;
transition-duration: 0.3s, 0.5s, 0.2s;
}3. transition-timing-function
控制过渡速度曲线。
css
/* 预定义函数 */
.linear { transition-timing-function: linear; }
.ease { transition-timing-function: ease; } /* 默认 */
.ease-in { transition-timing-function: ease-in; }
.ease-out { transition-timing-function: ease-out; }
.ease-in-out { transition-timing-function: ease-in-out; }
/* 自定义贝塞尔曲线 */
.custom {
transition-timing-function: cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
/* 分段函数 */
.steps {
transition-timing-function: steps(4, end);
}4. transition-delay
设置过渡延迟时间。
css
/* 立即开始 */
.immediate {
transition-delay: 0s;
}
/* 延迟开始 */
.delayed {
transition-delay: 0.2s;
}
/* 负延迟(提前开始) */
.early {
transition-delay: -0.1s;
}简写语法
css
/* 完整语法 */
.element {
transition: property duration timing-function delay;
}
/* 示例 */
.card {
transition: transform 0.3s ease-out 0.1s;
}
/* 多个过渡 */
.complex {
transition:
transform 0.3s ease-out,
opacity 0.2s linear,
box-shadow 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}React中使用CSS Transitions
基础实现
tsx
import { useState } from 'react';
import './Button.css';
function AnimatedButton() {
const [isHovered, setIsHovered] = useState(false);
return (
<button
className={`animated-button ${isHovered ? 'hovered' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
Hover Me
</button>
);
}css
/* Button.css */
.animated-button {
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
/* 过渡配置 */
transition:
background-color 0.3s ease,
transform 0.2s ease,
box-shadow 0.3s ease;
}
.animated-button.hovered {
background-color: #2563eb;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}条件过渡
tsx
interface CardProps {
isExpanded: boolean;
onToggle: () => void;
}
function ExpandableCard({ isExpanded, onToggle }: CardProps) {
return (
<div className={`card ${isExpanded ? 'expanded' : 'collapsed'}`}>
<div className="card-header" onClick={onToggle}>
<h3>Card Title</h3>
<span className={`arrow ${isExpanded ? 'up' : 'down'}`}>▼</span>
</div>
<div className="card-content">
<p>Card content goes here...</p>
</div>
</div>
);
}css
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.card-header {
padding: 16px;
background-color: #f9fafb;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.arrow {
transition: transform 0.3s ease;
}
.arrow.up {
transform: rotate(180deg);
}
.card-content {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
}
.card.expanded .card-content {
max-height: 500px;
padding: 16px;
}性能优化
1. 使用transform和opacity
tsx
// ✅ 高性能 - 使用transform和opacity
function OptimizedModal({ isOpen }: { isOpen: boolean }) {
if (!isOpen) return null;
return (
<div className={`modal-overlay ${isOpen ? 'visible' : ''}`}>
<div className={`modal-content ${isOpen ? 'visible' : ''}`}>
<h2>Modal Title</h2>
<p>Modal content...</p>
</div>
</div>
);
}css
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
/* 高性能属性 */
opacity: 0;
transition: opacity 0.3s ease;
}
.modal-overlay.visible {
opacity: 1;
}
.modal-content {
position: fixed;
top: 50%;
left: 50%;
background: white;
border-radius: 8px;
padding: 24px;
/* 使用transform替代top/left过渡 */
transform: translate(-50%, -50%) scale(0.9);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-content.visible {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}2. 避免布局抖动
css
/* ❌ 会触发布局重排 */
.bad {
transition: width 0.3s ease;
}
.bad:hover {
width: 300px; /* 影响布局 */
}
/* ✅ 使用transform */
.good {
transition: transform 0.3s ease;
}
.good:hover {
transform: scaleX(1.2); /* 不影响布局 */
}3. will-change提示
css
.optimized {
/* 提示浏览器即将变化的属性 */
will-change: transform, opacity;
transition: transform 0.3s ease, opacity 0.3s ease;
}
/* 注意: 不要过度使用will-change */
.overused {
/* ❌ 不要对所有元素使用 */
will-change: transform, opacity, width, height, left, top;
}高级过渡技巧
链式过渡
tsx
function ChainedTransition() {
const [step, setStep] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
setStep((s) => (s + 1) % 3);
}, 2000);
return () => clearTimeout(timer);
}, [step]);
return (
<div className={`chained step-${step}`}>
<div className="box">Animated Box</div>
</div>
);
}css
.chained .box {
width: 100px;
height: 100px;
background-color: #3b82f6;
transition:
transform 0.5s ease,
background-color 0.5s ease 0.5s, /* 延迟0.5s */
border-radius 0.5s ease 1s; /* 延迟1s */
}
.chained.step-1 .box {
transform: translateX(100px);
}
.chained.step-2 .box {
transform: translateX(100px) rotate(45deg);
background-color: #10b981;
}
.chained.step-3 .box {
transform: translateX(100px) rotate(45deg);
background-color: #10b981;
border-radius: 50%;
}交错动画
tsx
function StaggeredList({ items }: { items: string[] }) {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
setIsVisible(true);
}, []);
return (
<ul className={`staggered-list ${isVisible ? 'visible' : ''}`}>
{items.map((item, index) => (
<li
key={index}
className="list-item"
style={{ transitionDelay: `${index * 0.1}s` }}
>
{item}
</li>
))}
</ul>
);
}css
.list-item {
padding: 12px;
background-color: white;
border: 1px solid #e5e7eb;
margin-bottom: 8px;
opacity: 0;
transform: translateX(-20px);
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.staggered-list.visible .list-item {
opacity: 1;
transform: translateX(0);
}响应式过渡
tsx
function ResponsiveCard() {
return (
<div className="responsive-card">
<div className="card-image">
<img src="/image.jpg" alt="Card" />
</div>
<div className="card-body">
<h3>Card Title</h3>
<p>Card description...</p>
</div>
</div>
);
}css
.responsive-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s ease;
}
.responsive-card:hover {
transform: translateY(-4px);
}
.card-image {
width: 100%;
height: 200px;
overflow: hidden;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
}
.responsive-card:hover .card-image img {
transform: scale(1.1);
}
/* 移动端禁用悬停效果 */
@media (hover: none) {
.responsive-card:hover {
transform: none;
}
.responsive-card:hover .card-image img {
transform: none;
}
}实战案例
1. 加载按钮
tsx
function LoadingButton() {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
setIsLoading(true);
try {
await fetch('/api/data');
} finally {
setIsLoading(false);
}
};
return (
<button
className={`loading-button ${isLoading ? 'loading' : ''}`}
onClick={handleClick}
disabled={isLoading}
>
<span className="button-text">Submit</span>
<span className="spinner"></span>
</button>
);
}css
.loading-button {
position: relative;
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
overflow: hidden;
transition: background-color 0.3s ease;
}
.loading-button:hover:not(:disabled) {
background-color: #2563eb;
}
.loading-button:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.button-text {
display: inline-block;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.loading-button.loading .button-text {
opacity: 0;
transform: translateY(-10px);
}
.spinner {
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
opacity: 0;
transform: translate(-50%, -50%) scale(0);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.loading-button.loading .spinner {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: translate(-50%, -50%) rotate(360deg) scale(1); }
}2. 通知Toast
tsx
interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'info';
}
function ToastContainer() {
const [toasts, setToasts] = useState<Toast[]>([]);
const addToast = (message: string, type: Toast['type']) => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
removeToast(id);
}, 3000);
};
const removeToast = (id: number) => {
setToasts(prev => prev.filter(t => t.id !== id));
};
return (
<div className="toast-container">
{toasts.map((toast) => (
<div
key={toast.id}
className={`toast toast-${toast.type}`}
>
{toast.message}
<button
className="toast-close"
onClick={() => removeToast(toast.id)}
>
×
</button>
</div>
))}
</div>
);
}css
.toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
min-width: 300px;
padding: 16px;
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
display: flex;
justify-content: space-between;
align-items: center;
/* 入场动画 */
animation: slideIn 0.3s ease;
/* 退场过渡 */
opacity: 1;
transform: translateX(0);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.toast.removing {
opacity: 0;
transform: translateX(100%);
}
.toast-success { border-left: 4px solid #10b981; }
.toast-error { border-left: 4px solid #ef4444; }
.toast-info { border-left: 4px solid #3b82f6; }
.toast-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #6b7280;
padding: 0 4px;
transition: color 0.2s ease;
}
.toast-close:hover {
color: #111827;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}3. 图片画廊
tsx
function ImageGallery({ images }: { images: string[] }) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
return (
<>
<div className="gallery-grid">
{images.map((image, index) => (
<div
key={index}
className="gallery-item"
onClick={() => setSelectedIndex(index)}
>
<img src={image} alt={`Image ${index + 1}`} />
<div className="gallery-overlay">
<span>View</span>
</div>
</div>
))}
</div>
{selectedIndex !== null && (
<div
className="lightbox"
onClick={() => setSelectedIndex(null)}
>
<img
src={images[selectedIndex]}
alt={`Image ${selectedIndex + 1}`}
/>
</div>
)}
</>
);
}css
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.gallery-item {
position: relative;
aspect-ratio: 1;
overflow: hidden;
border-radius: 8px;
cursor: pointer;
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.gallery-item:hover img {
transform: scale(1.1);
}
.gallery-overlay {
position: absolute;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
opacity: 0;
transition: opacity 0.3s ease;
}
.gallery-item:hover .gallery-overlay {
opacity: 1;
}
.lightbox {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
.lightbox img {
max-width: 90%;
max-height: 90%;
border-radius: 8px;
animation: scaleIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}4. 进度指示器
tsx
function ProgressBar({ value, max = 100 }: { value: number; max?: number }) {
const percentage = (value / max) * 100;
return (
<div className="progress-container">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${percentage}%` }}
>
<span className="progress-text">{percentage.toFixed(0)}%</span>
</div>
</div>
</div>
);
}
function CircularProgress({ value, max = 100 }: { value: number; max?: number }) {
const radius = 45;
const circumference = 2 * Math.PI * radius;
const offset = circumference - (value / max) * circumference;
return (
<svg className="circular-progress" width="120" height="120">
<circle
className="progress-bg"
cx="60"
cy="60"
r={radius}
/>
<circle
className="progress-ring"
cx="60"
cy="60"
r={radius}
style={{
strokeDasharray: circumference,
strokeDashoffset: offset,
}}
/>
<text x="60" y="60" className="progress-percentage">
{((value / max) * 100).toFixed(0)}%
</text>
</svg>
);
}css
/* 线性进度条 */
.progress-container {
width: 100%;
padding: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 8px;
transition: width 0.5s ease;
}
.progress-text {
font-size: 10px;
color: white;
font-weight: bold;
}
/* 环形进度条 */
.circular-progress {
transform: rotate(-90deg);
}
.progress-bg {
fill: none;
stroke: #e5e7eb;
stroke-width: 10;
}
.progress-ring {
fill: none;
stroke: url(#gradient);
stroke-width: 10;
stroke-linecap: round;
transition: stroke-dashoffset 0.5s ease;
}
.progress-percentage {
transform: rotate(90deg);
transform-origin: center;
text-anchor: middle;
dominant-baseline: central;
font-size: 18px;
font-weight: bold;
fill: #3b82f6;
}过渡事件监听
transitionend事件
tsx
function TransitionListener() {
const [isExpanded, setIsExpanded] = useState(false);
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
const handleTransitionEnd = (e: TransitionEvent) => {
if (e.propertyName === 'max-height') {
console.log('Height transition completed');
// 过渡完成后的操作
if (isExpanded) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
};
element.addEventListener('transitionend', handleTransitionEnd);
return () => {
element.removeEventListener('transitionend', handleTransitionEnd);
};
}, [isExpanded]);
return (
<div>
<button onClick={() => setIsExpanded(!isExpanded)}>
Toggle
</button>
<div
ref={elementRef}
className={`expandable ${isExpanded ? 'expanded' : ''}`}
>
Content here...
</div>
</div>
);
}自定义Hook封装
tsx
function useTransition(
ref: RefObject<HTMLElement>,
onTransitionEnd?: (propertyName: string) => void
) {
useEffect(() => {
const element = ref.current;
if (!element) return;
const handleTransitionEnd = (e: TransitionEvent) => {
onTransitionEnd?.(e.propertyName);
};
element.addEventListener('transitionend', handleTransitionEnd);
return () => {
element.removeEventListener('transitionend', handleTransitionEnd);
};
}, [ref, onTransitionEnd]);
}
// 使用
function Component() {
const ref = useRef<HTMLDivElement>(null);
useTransition(ref, (property) => {
console.log(`${property} transition completed`);
});
return <div ref={ref} className="animated">...</div>;
}调试与优化
性能分析
tsx
function PerformanceMonitor() {
const startTime = useRef(0);
const handleTransitionStart = () => {
startTime.current = performance.now();
};
const handleTransitionEnd = (e: TransitionEvent) => {
const duration = performance.now() - startTime.current;
console.log(`${e.propertyName} took ${duration}ms`);
};
return (
<div
className="monitored"
onTransitionStart={handleTransitionStart}
onTransitionEnd={handleTransitionEnd}
>
Monitored Element
</div>
);
}降级方案
css
/* 检测是否支持transition */
@supports (transition: transform 0.3s ease) {
.modern {
transition: transform 0.3s ease;
}
.modern:hover {
transform: scale(1.1);
}
}
/* 不支持时的降级方案 */
@supports not (transition: transform 0.3s ease) {
.modern:hover {
/* 立即变化或使用其他效果 */
opacity: 0.8;
}
}减弱动画偏好
css
/* 尊重用户的动画偏好 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 为重视可访问性的用户提供选项 */
@media (prefers-reduced-motion: reduce) {
.animated {
transition: none;
}
}最佳实践总结
性能优化清单
✅ 优先使用transform和opacity
✅ 避免过渡width、height等布局属性
✅ 合理使用will-change提示
✅ 限制同时过渡的元素数量
✅ 使用CSS containment隔离影响范围
✅ 避免过度使用all属性
✅ 为长列表使用虚拟滚动
✅ 测试不同设备的性能表现可访问性要求
✅ 提供prefers-reduced-motion支持
✅ 确保过渡不影响键盘导航
✅ 保持足够的颜色对比度
✅ 避免仅依赖颜色传达信息
✅ 提供可跳过动画的选项
✅ 测试屏幕阅读器兼容性用户体验准则
✅ 保持过渡时长在150-300ms之间
✅ 使用合适的缓动函数
✅ 为不同交互使用不同时长
✅ 确保过渡有明确的开始和结束
✅ 避免过度复杂的过渡效果
✅ 提供即时反馈CSS Transitions为React应用提供了简单高效的动画解决方案。通过合理配置过渡属性,结合React的状态管理,可以创建流畅自然的用户交互体验。掌握这些技巧后,你将能够为应用添加恰到好处的动画效果,提升整体用户体验。