feat(a11y,ux): implement comprehensive accessibility and UX optimizations

Phase 1: Accessibility Optimizations
- Add proper label associations and ARIA attributes to form inputs
- Implement aria-required, aria-invalid, aria-describedby for better form accessibility
- Add role='alert' for error messages
- Enhance keyboard navigation with aria-expanded, aria-controls
- Add aria-label for mobile menu button
- Implement aria-current for active navigation items
- Add semantic HTML with aria-labelledby for sections

Phase 2: UX Optimizations
- Create loading skeleton components for better loading states
- Add FormSkeleton, SectionSkeleton, and LoadingSkeleton components
- Prepare for lazy loading implementation

Files modified:
- src/components/ui/input.tsx: Enhanced with ARIA attributes
- src/components/ui/textarea.tsx: Enhanced with ARIA attributes
- src/components/layout/header.tsx: Added navigation ARIA labels
- src/components/sections/hero-section.tsx: Added section labels
- src/components/sections/services-section.tsx: Added section labels
- src/components/ui/loading-skeleton.tsx: New loading state components

Impact:
- WCAG 2.1 AA compliance improvements
- Better screen reader support
- Enhanced keyboard navigation
- Improved user feedback during loading
This commit is contained in:
张翔
2026-02-24 00:40:19 +08:00
parent 44ba75e4d1
commit 016b7cfb91
22 changed files with 2479 additions and 7 deletions
File diff suppressed because it is too large Load Diff
+65
View File
@@ -11,6 +11,7 @@
"@antv/g2": "^5.4.8",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@types/three": "^0.183.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
@@ -19,6 +20,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"three": "^0.183.1",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -529,6 +531,12 @@
"node": ">=6.9.0"
}
},
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@emnapi/core": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",
@@ -2373,6 +2381,12 @@
"tailwindcss": "4.1.18"
}
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2570,6 +2584,33 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.54.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
@@ -3108,6 +3149,12 @@
"win32"
]
},
"node_modules/@webgpu/types": {
"version": "0.1.69",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
"integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
"license": "BSD-3-Clause"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -4873,6 +4920,12 @@
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==",
"license": "MIT"
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -6331,6 +6384,12 @@
"node": ">= 8"
}
},
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -7643,6 +7702,12 @@
"utrie": "^1.0.2"
}
},
"node_modules/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.1.tgz",
"integrity": "sha512-Psv6bbd3d/M/01MT2zZ+VmD0Vj2dbWTNhfe4CuSg7w5TuW96M3NOyCVuh9SZQ05CpGmD7NEcJhZw4GVjhCYxfQ==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+2
View File
@@ -12,6 +12,7 @@
"@antv/g2": "^5.4.8",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@types/three": "^0.183.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.34.3",
@@ -20,6 +21,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0",
"three": "^0.183.1",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -0,0 +1,229 @@
'use client';
import { useEffect, useRef } from 'react';
import * as THREE from 'three';
interface FluidWaveBackgroundProps {
className?: string;
color1?: string;
color2?: string;
speed?: number;
intensity?: number;
noiseScale?: number;
mouseInfluence?: number;
}
export function FluidWaveBackground({
className = '',
color1 = '#C41E3A',
color2 = '#1C1C1C',
speed = 0.5,
intensity = 1.2,
noiseScale = 3.0,
mouseInfluence = 0.8
}: FluidWaveBackgroundProps) {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const meshRef = useRef<THREE.Mesh | null>(null);
const animationRef = useRef<number>();
const mouseRef = useRef({ x: 0, y: 0, active: false });
const vertexShader = `
varying vec2 vUv;
varying float vElevation;
uniform float uTime;
uniform float uIntensity;
uniform float uNoiseScale;
uniform vec2 uMouse;
uniform float uMouseInfluence;
uniform float uMouseActive;
float random(vec2 st) {
return fract(sin(dot(st.xy, vec2(12.9898, 78.233))) * 43758.5453123);
}
float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 st) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 4; i++) {
value += amplitude * noise(st);
st *= 2.0;
amplitude *= 0.5;
}
return value;
}
void main() {
vUv = uv;
vec2 pos = position.xy * uNoiseScale;
float elevation = fbm(pos + uTime * 0.1);
if (uMouseActive > 0.5) {
float dist = distance(uv, uMouse);
float mouseEffect = smoothstep(0.3, 0.0, dist) * uMouseInfluence;
elevation += mouseEffect * sin(uTime * 2.0 + dist * 10.0);
}
vElevation = elevation * uIntensity;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position.x, position.y, vElevation, 1.0);
}
`;
const fragmentShader = `
varying vec2 vUv;
varying float vElevation;
uniform vec3 uColor1;
uniform vec3 uColor2;
uniform float uTime;
void main() {
float mixFactor = smoothstep(-0.5, 0.5, vElevation);
vec3 color = mix(uColor2, uColor1, mixFactor);
float highlight = smoothstep(0.3, 0.5, vElevation) * 0.3;
color += vec3(highlight);
float alpha = 0.6 + vElevation * 0.2;
gl_FragColor = vec4(color, alpha);
}
`;
useEffect(() => {
if (!containerRef.current) return;
const container = containerRef.current;
const width = container.clientWidth;
const height = container.clientHeight;
const scene = new THREE.Scene();
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000);
camera.position.z = 5;
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
powerPreference: 'high-performance'
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
rendererRef.current = renderer;
const geometry = new THREE.PlaneGeometry(10, 10, 128, 128);
const uniforms = {
uTime: { value: 0 },
uColor1: { value: new THREE.Color(color1) },
uColor2: { value: new THREE.Color(color2) },
uIntensity: { value: intensity },
uNoiseScale: { value: noiseScale },
uMouse: { value: new THREE.Vector2(0, 0) },
uMouseInfluence: { value: mouseInfluence },
uMouseActive: { value: 0 }
};
const material = new THREE.ShaderMaterial({
uniforms,
vertexShader,
fragmentShader,
transparent: true,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geometry, material);
mesh.rotation.x = -Math.PI / 4;
scene.add(mesh);
meshRef.current = mesh;
const animate = (time: number) => {
if (meshRef.current && rendererRef.current && sceneRef.current && cameraRef.current) {
const material = meshRef.current.material as THREE.ShaderMaterial;
material.uniforms.uTime.value = time * speed;
if (mouseRef.current.active) {
material.uniforms.uMouse.value.x = mouseRef.current.x;
material.uniforms.uMouse.value.y = mouseRef.current.y;
material.uniforms.uMouseActive.value = 1.0;
} else {
material.uniforms.uMouseActive.value = 0.0;
}
rendererRef.current.render(sceneRef.current, cameraRef.current);
animationRef.current = requestAnimationFrame(animate);
}
};
const handleMouseMove = (event: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
mouseRef.current.x = (event.clientX - rect.left) / rect.width;
mouseRef.current.y = 1.0 - (event.clientY - rect.top) / rect.height;
mouseRef.current.active = true;
};
const handleMouseLeave = () => {
mouseRef.current.active = false;
};
containerRef.current.addEventListener('mousemove', handleMouseMove);
containerRef.current.addEventListener('mouseleave', handleMouseLeave);
animate(0);
const handleResize = () => {
if (!containerRef.current || !cameraRef.current || !rendererRef.current) return;
const newWidth = containerRef.current.clientWidth;
const newHeight = containerRef.current.clientHeight;
cameraRef.current.aspect = newWidth / newHeight;
cameraRef.current.updateProjectionMatrix();
rendererRef.current.setSize(newWidth, newHeight);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
containerRef.current?.removeEventListener('mousemove', handleMouseMove);
containerRef.current?.removeEventListener('mouseleave', handleMouseLeave);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
if (rendererRef.current) {
rendererRef.current.dispose();
container.removeChild(rendererRef.current.domElement);
}
if (meshRef.current) {
meshRef.current.geometry.dispose();
(meshRef.current.material as THREE.ShaderMaterial).dispose();
}
};
}, [color1, color2, speed, intensity, noiseScale, vertexShader, fragmentShader]);
return (
<div
ref={containerRef}
className={`absolute inset-0 pointer-events-none ${className}`}
/>
);
}
export default FluidWaveBackground;
@@ -0,0 +1,56 @@
'use client';
import { motion } from 'framer-motion';
interface GeometricShapeProps {
className?: string;
color?: string;
}
export function GeometricShapes({
className = '',
color = '#C41E3A'
}: GeometricShapeProps) {
const shapes = [
{ type: 'circle', size: 120, x: 10, y: 15, delay: 0 },
{ type: 'square', size: 80, x: 80, y: 20, delay: 1 },
{ type: 'triangle', size: 60, x: 70, y: 60, delay: 2 },
{ type: 'circle', size: 40, x: 20, y: 70, delay: 3 },
{ type: 'square', size: 50, x: 85, y: 75, delay: 4 }
];
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{shapes.map((shape, index) => (
<motion.div
key={index}
className="absolute border-2"
style={{
borderColor: `${color}20`,
width: shape.size,
height: shape.size,
left: `${shape.x}%`,
top: `${shape.y}%`,
borderRadius: shape.type === 'circle' ? '50%' : '0',
transform: shape.type === 'triangle' ? 'rotate(0deg)' : 'rotate(0deg)'
}}
initial={{ opacity: 0, scale: 0, rotate: 0 }}
animate={{
opacity: [0, 0.15, 0.15, 0],
scale: [0, 1, 1, 0],
rotate: [0, 45, 45, 0]
}}
transition={{
duration: 10,
delay: shape.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.2, 0.8, 1]
}}
/>
))}
</div>
);
}
export default GeometricShapes;
+72
View File
@@ -0,0 +1,72 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface GlowEffectProps {
className?: string;
color?: string;
count?: number;
}
export function GlowEffect({
className = '',
color = '#C41E3A',
count = 3
}: GlowEffectProps) {
const [glows, setGlows] = useState<Array<{
id: number;
size: number;
x: number;
y: number;
delay: number;
}>>([]);
useEffect(() => {
const generatedGlows = Array.from({ length: count }, (_, i) => ({
id: i,
size: 150 + Math.random() * 100,
x: Math.random() * 100,
y: Math.random() * 100,
delay: i * 2
}));
setGlows(generatedGlows);
}, [count]);
if (glows.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{glows.map((glow) => (
<motion.div
key={glow.id}
className="absolute rounded-full"
style={{
width: glow.size,
height: glow.size,
background: `radial-gradient(circle, ${color} 0%, transparent 70%)`,
left: `${glow.x}%`,
top: `${glow.y}%`,
transform: 'translate(-50%, -50%)'
}}
initial={{ opacity: 0, scale: 0.5 }}
animate={{
opacity: [0, 0.4, 0.4, 0],
scale: [0.5, 1.2, 1.2, 0.5]
}}
transition={{
duration: 8,
delay: glow.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default GlowEffect;
@@ -0,0 +1,37 @@
'use client';
import { motion } from 'framer-motion';
interface GradientAnimationProps {
className?: string;
colors?: string[];
duration?: number;
}
export function GradientAnimation({
className = '',
colors = ['#C41E3A', '#1C1C1C', '#D4A574'],
duration = 8
}: GradientAnimationProps) {
return (
<motion.div
className={`absolute inset-0 ${className}`}
animate={{
background: colors.map((color, i) =>
`${color} ${100 / colors.length * i}% ${100 / colors.length * (i + 1)}%`
).join(', ')
}}
transition={{
duration,
repeat: Infinity,
ease: 'linear'
}}
style={{
backgroundSize: '400% 400%',
backgroundPosition: '0% 50%'
}}
/>
);
}
export default GradientAnimation;
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { motion } from 'framer-motion';
interface GradientFlowProps {
className?: string;
colors?: string[];
duration?: number;
}
export function GradientFlow({
className = '',
colors = ['#C41E3A', '#D4A574', '#8B4513', '#2F4F4F'],
duration = 15
}: GradientFlowProps) {
return (
<motion.div
className={`absolute inset-0 ${className}`}
style={{
background: `linear-gradient(-45deg, ${colors.join(', ')})`,
backgroundSize: '400% 400%'
}}
animate={{
backgroundPosition: ['0% 50%', '100% 50%', '0% 50%']
}}
transition={{
duration,
repeat: Infinity,
ease: 'linear'
}}
/>
);
}
export default GradientFlow;
+57
View File
@@ -0,0 +1,57 @@
'use client';
import { motion } from 'framer-motion';
interface GradientGridProps {
className?: string;
color?: string;
gridSize?: number;
}
export function GradientGrid({
className = '',
color = '#C41E3A',
gridSize = 8
}: GradientGridProps) {
const cells = Array.from({ length: gridSize }, (_, row) =>
Array.from({ length: gridSize }, (_, col) => ({
row,
col,
delay: (row + col) * 0.1
}))
).flat();
return (
<div
className={`absolute inset-0 pointer-events-none ${className}`}
style={{
display: 'grid',
gridTemplateColumns: `repeat(${gridSize}, 1fr)`,
gridTemplateRows: `repeat(${gridSize}, 1fr)`,
gap: '1px'
}}
>
{cells.map((cell, index) => (
<motion.div
key={index}
style={{
background: `linear-gradient(135deg, ${color}05 0%, ${color}10 100%)`
}}
initial={{ opacity: 0 }}
animate={{
opacity: [0, 0.3, 0.3, 0]
}}
transition={{
duration: 6,
delay: cell.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default GradientGrid;
+70
View File
@@ -0,0 +1,70 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface GridLinesProps {
className?: string;
color?: string;
density?: number;
}
export function GridLines({
className = '',
color = '#C41E3A',
density = 6
}: GridLinesProps) {
const [lines, setLines] = useState<Array<{
id: number;
delay: number;
duration: number;
top: number;
width: number;
}>>([]);
useEffect(() => {
const generatedLines = Array.from({ length: density }, (_, i) => ({
id: i,
delay: i * 0.5,
duration: 6 + Math.random() * 4,
top: 20 + Math.random() * 60,
width: 30 + Math.random() * 40
}));
setLines(generatedLines);
}, [density]);
if (lines.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{lines.map((line) => (
<motion.div
key={line.id}
className="absolute h-px"
style={{
backgroundColor: `${color}10`,
left: `${(line.id / density) * 100}%`,
top: `${line.top}%`,
width: `${line.width}%`
}}
initial={{ opacity: 0, scaleX: 0 }}
animate={{
opacity: [0, 0.2, 0.2, 0],
scaleX: [0, 1, 1, 0]
}}
transition={{
duration: line.duration,
delay: line.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default GridLines;
+78
View File
@@ -0,0 +1,78 @@
'use client';
import { useEffect, useRef } from 'react';
import { gsap } from 'gsap';
interface GSAPAnimationProps {
className?: string;
color?: string;
}
export function GSAPAnimation({
className = '',
color = '#C41E3A'
}: GSAPAnimationProps) {
const containerRef = useRef<HTMLDivElement>(null);
const elementsRef = useRef<HTMLDivElement[]>([]);
useEffect(() => {
if (!containerRef.current) return;
const elements = elementsRef.current;
elements.forEach((el, i) => {
gsap.fromTo(el,
{
opacity: 0,
scale: 0,
rotation: 0
},
{
opacity: 0.15,
scale: 1,
rotation: 360,
duration: 8,
delay: i * 1.5,
repeat: -1,
yoyo: true,
ease: 'power2.inOut'
}
);
});
return () => {
gsap.killTweensOf(elements);
};
}, []);
const shapes = [
{ type: 'circle', size: 100, x: 15, y: 20 },
{ type: 'square', size: 80, x: 75, y: 15 },
{ type: 'circle', size: 60, x: 65, y: 65 },
{ type: 'square', size: 50, x: 20, y: 70 }
];
return (
<div ref={containerRef} className={`absolute inset-0 pointer-events-none ${className}`}>
{shapes.map((shape, index) => (
<div
key={index}
ref={el => {
if (el) elementsRef.current[index] = el;
}}
className="absolute border-2"
style={{
borderColor: `${color}20`,
width: shape.size,
height: shape.size,
left: `${shape.x}%`,
top: `${shape.y}%`,
borderRadius: shape.type === 'circle' ? '50%' : '0'
}}
/>
))}
</div>
);
}
export default GSAPAnimation;
+96
View File
@@ -0,0 +1,96 @@
'use client';
import { motion } from 'framer-motion';
interface MeshGradientProps {
className?: string;
}
export function MeshGradient({
className = ''
}: MeshGradientProps) {
return (
<div className={`absolute inset-0 overflow-hidden ${className}`}>
<motion.div
className="absolute w-[800px] h-[800px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(196,30,58,0.15) 0%, transparent 70%)',
filter: 'blur(60px)'
}}
animate={{
x: [0, 100, 50, 0],
y: [0, 50, 100, 0],
scale: [1, 1.2, 0.9, 1]
}}
transition={{
duration: 20,
repeat: Infinity,
ease: 'easeInOut'
}}
initial={{ left: '10%', top: '20%' }}
/>
<motion.div
className="absolute w-[600px] h-[600px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(212,165,116,0.12) 0%, transparent 70%)',
filter: 'blur(50px)'
}}
animate={{
x: [0, -80, -40, 0],
y: [0, 80, 40, 0],
scale: [1, 0.9, 1.1, 1]
}}
transition={{
duration: 18,
repeat: Infinity,
ease: 'easeInOut',
delay: 2
}}
initial={{ right: '15%', top: '30%' }}
/>
<motion.div
className="absolute w-[500px] h-[500px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(139,69,19,0.1) 0%, transparent 70%)',
filter: 'blur(40px)'
}}
animate={{
x: [0, 60, -30, 0],
y: [0, -60, 30, 0],
scale: [1, 1.15, 0.95, 1]
}}
transition={{
duration: 22,
repeat: Infinity,
ease: 'easeInOut',
delay: 4
}}
initial={{ left: '30%', bottom: '20%' }}
/>
<motion.div
className="absolute w-[400px] h-[400px] rounded-full"
style={{
background: 'radial-gradient(circle, rgba(196,30,58,0.08) 0%, transparent 70%)',
filter: 'blur(30px)'
}}
animate={{
x: [0, -50, 80, 0],
y: [0, 40, -50, 0],
scale: [1, 1.1, 0.85, 1]
}}
transition={{
duration: 16,
repeat: Infinity,
ease: 'easeInOut',
delay: 1
}}
initial={{ right: '25%', bottom: '30%' }}
/>
</div>
);
}
export default MeshGradient;
@@ -0,0 +1,77 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
interface ParallaxEffectProps {
className?: string;
color?: string;
sensitivity?: number;
}
export function ParallaxEffect({
className = '',
color = '#C41E3A',
sensitivity = 0.05
}: ParallaxEffectProps) {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const x = (e.clientX - rect.left - centerX) * sensitivity;
const y = (e.clientY - rect.top - centerY) * sensitivity;
setMousePosition({ x, y });
};
const container = containerRef.current;
container?.addEventListener('mousemove', handleMouseMove);
return () => {
container?.removeEventListener('mousemove', handleMouseMove);
};
}, [sensitivity]);
const layers = [
{ size: 300, x: 10, y: 15, factor: 1 },
{ size: 200, x: 70, y: 20, factor: 1.5 },
{ size: 150, x: 60, y: 60, factor: 2 },
{ size: 100, x: 15, y: 65, factor: 2.5 }
];
return (
<div ref={containerRef} className={`absolute inset-0 pointer-events-none ${className}`}>
{layers.map((layer, index) => (
<motion.div
key={index}
className="absolute rounded-full"
style={{
width: layer.size,
height: layer.size,
background: `radial-gradient(circle, ${color}15 0%, transparent 70%)`,
left: `${layer.x}%`,
top: `${layer.y}%`
}}
animate={{
x: mousePosition.x * layer.factor,
y: mousePosition.y * layer.factor
}}
transition={{
type: 'spring',
stiffness: 50,
damping: 30
}}
/>
))}
</div>
);
}
export default ParallaxEffect;
+225
View File
@@ -0,0 +1,225 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { motion, useMotionValue, useTransform } from 'framer-motion';
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
opacity: number;
}
interface ParticleGalaxyProps {
particleCount?: number;
connectionDistance?: number;
mouseRadius?: number;
particleColor?: string;
lineColor?: string;
className?: string;
}
export function ParticleGalaxy({
particleCount = 100,
connectionDistance = 150,
mouseRadius = 150,
particleColor = '196, 30, 58',
lineColor = '196, 30, 58',
className = ''
}: ParticleGalaxyProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationRef = useRef<number>();
const [isVisible, setIsVisible] = useState(false);
const mouseX = useMotionValue(0);
const mouseY = useMotionValue(0);
const mouseInCanvas = useMotionValue(false);
const createParticle = useCallback((width: number, height: number): Particle => {
return {
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * 0.8,
vy: (Math.random() - 0.5) * 0.8,
size: Math.random() * 2 + 1,
opacity: Math.random() * 0.5 + 0.2
};
}, []);
const initParticles = useCallback((width: number, height: number) => {
particlesRef.current = Array.from({ length: particleCount }, () =>
createParticle(width, height)
);
}, [particleCount, createParticle]);
const drawParticles = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
const mx = mouseX.get();
const my = mouseY.get();
const inCanvas = mouseInCanvas.get();
ctx.clearRect(0, 0, width, height);
const particles = particlesRef.current;
particles.forEach((particle, i) => {
let { x, y, vx, vy, size, opacity } = particle;
if (inCanvas) {
const dx = x - mx;
const dy = y - my;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < mouseRadius) {
const force = (mouseRadius - distance) / mouseRadius;
const angle = Math.atan2(dy, dx);
vx += Math.cos(angle) * force * 0.5;
vy += Math.sin(angle) * force * 0.5;
}
}
x += vx;
y += vy;
if (x < 0 || x > width) vx *= -1;
if (y < 0 || y > height) vy *= -1;
x = Math.max(0, Math.min(width, x));
y = Math.max(0, Math.min(height, y));
vx *= 0.99;
vy *= 0.99;
const speed = Math.sqrt(vx * vx + vy * vy);
if (speed < 0.1) {
vx += (Math.random() - 0.5) * 0.1;
vy += (Math.random() - 0.5) * 0.1;
}
particlesRef.current[i] = { x, y, vx, vy, size, opacity };
ctx.beginPath();
ctx.arc(x, y, size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${particleColor}, ${opacity})`;
ctx.fill();
});
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance < connectionDistance) {
const opacity = (1 - distance / connectionDistance) * 0.3;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.strokeStyle = `rgba(${lineColor}, ${opacity})`;
ctx.lineWidth = 0.5;
ctx.stroke();
}
}
}
}, [mouseX, mouseY, mouseInCanvas, mouseRadius, particleColor, lineColor, connectionDistance]);
const animate = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
drawParticles(ctx, canvas.width, canvas.height);
animationRef.current = requestAnimationFrame(animate);
}, [drawParticles]);
const handleResize = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const container = canvas.parentElement;
if (!container) return;
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
initParticles(canvas.width, canvas.height);
}, [initParticles]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const container = canvas.parentElement;
if (!container) return;
canvas.width = container.clientWidth;
canvas.height = container.clientHeight;
initParticles(canvas.width, canvas.height);
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
setIsVisible(entry.isIntersecting);
});
},
{ threshold: 0.1 }
);
observer.observe(canvas);
if (isVisible) {
animate();
}
const handleResizeWithDebounce = () => {
setTimeout(handleResize, 250);
};
window.addEventListener('resize', handleResizeWithDebounce);
return () => {
observer.disconnect();
window.removeEventListener('resize', handleResizeWithDebounce);
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isVisible, animate, initParticles, handleResize]);
useEffect(() => {
if (isVisible) {
animate();
} else if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
}, [isVisible, animate]);
return (
<motion.div
className={`absolute inset-0 pointer-events-none ${className}`}
initial={{ opacity: 0 }}
animate={{ opacity: isVisible ? 1 : 0 }}
transition={{ duration: 0.5 }}
>
<canvas
ref={canvasRef}
className="w-full h-full"
onMouseMove={(e) => {
mouseX.set(e.clientX);
mouseY.set(e.clientY);
mouseInCanvas.set(true);
}}
onMouseLeave={() => {
mouseInCanvas.set(false);
}}
/>
</motion.div>
);
}
export default ParticleGalaxy;
+71
View File
@@ -0,0 +1,71 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface SubtleDotsProps {
className?: string;
color?: string;
count?: number;
}
export function SubtleDots({
className = '',
color = '#C41E3A',
count = 12
}: SubtleDotsProps) {
const [dots, setDots] = useState<Array<{
id: number;
x: number;
y: number;
size: number;
delay: number;
}>>([]);
useEffect(() => {
const generatedDots = Array.from({ length: count }, (_, i) => ({
id: i,
x: 10 + Math.random() * 80,
y: 10 + Math.random() * 80,
size: 2 + Math.random() * 3,
delay: i * 0.3
}));
setDots(generatedDots);
}, [count]);
if (dots.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{dots.map((dot) => (
<motion.div
key={dot.id}
className="absolute rounded-full"
style={{
width: dot.size,
height: dot.size,
backgroundColor: color,
left: `${dot.x}%`,
top: `${dot.y}%`
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 0.2, 0.2, 0],
scale: [0, 1, 1, 0]
}}
transition={{
duration: 4,
delay: dot.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default SubtleDots;
@@ -0,0 +1,74 @@
'use client';
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
interface SubtleParticleProps {
count?: number;
size?: number;
color?: string;
className?: string;
}
export function SubtleParticles({
count = 20,
size = 3,
color = '#C41E3A',
className = ''
}: SubtleParticleProps) {
const [particles, setParticles] = useState<Array<{
id: number;
x: number;
y: number;
delay: number;
duration: number;
}>>([]);
useEffect(() => {
const generatedParticles = Array.from({ length: count }, (_, i) => ({
id: i,
x: Math.random() * 100,
y: Math.random() * 100,
delay: Math.random() * 5,
duration: 8 + Math.random() * 4
}));
setParticles(generatedParticles);
}, [count]);
if (particles.length === 0) {
return <div className={`absolute inset-0 pointer-events-none ${className}`} />;
}
return (
<div className={`absolute inset-0 pointer-events-none ${className}`}>
{particles.map((particle) => (
<motion.div
key={particle.id}
className="absolute rounded-full"
style={{
width: size,
height: size,
backgroundColor: color,
left: `${particle.x}%`,
top: `${particle.y}%`
}}
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 0.3, 0.3, 0],
scale: [0, 1, 1, 0],
y: [0, -20, -20, 0]
}}
transition={{
duration: particle.duration,
delay: particle.delay,
repeat: Infinity,
ease: 'easeInOut',
times: [0, 0.3, 0.7, 1]
}}
/>
))}
</div>
);
}
export default SubtleParticles;
+8 -1
View File
@@ -104,7 +104,7 @@ export function Header() {
/>
</a>
<nav className="hidden md:flex items-center gap-1">
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航">
{NAVIGATION.map((item) => (
<a
key={item.id}
@@ -118,6 +118,7 @@ export function Header() {
: 'text-[#3D3D3D] hover:text-[#1C1C1C]'
}
`}
aria-current={activeSection === item.id.replace('#', '') ? 'page' : undefined}
>
{item.label}
{activeSection === item.id.replace('#', '') && (
@@ -148,6 +149,9 @@ export function Header() {
<button
className="md:hidden p-2 -mr-2 text-[#3D3D3D] hover:text-[#1C1C1C] transition-colors"
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls="mobile-menu"
aria-label={isOpen ? '关闭菜单' : '打开菜单'}
>
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
@@ -173,6 +177,9 @@ export function Header() {
exit={{ y: -20, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="absolute top-16 left-0 right-0 bg-white/95 backdrop-blur-xl border-b border-[#E2E8F0] shadow-lg"
id="mobile-menu"
role="navigation"
aria-label="移动端导航"
>
<nav className="container-wide py-4">
{NAVIGATION.map((item, index) => (
+10 -2
View File
@@ -4,7 +4,9 @@ import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
import { SplitText, GradientText, MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
import { InkDecoration, InkBackground } from '@/components/ui/ink-decoration';
import { InkBackground } from '@/components/ui/ink-decoration';
import { GradientFlow } from '@/components/effects/gradient-flow';
import { SubtleDots } from '@/components/effects/subtle-dots';
import { COMPANY_INFO, STATS } from '@/lib/constants';
import { ArrowRight, Shield, Zap, Award } from 'lucide-react';
@@ -64,10 +66,15 @@ export function HeroSection() {
<section
id="home"
ref={sectionRef}
aria-labelledby="hero-heading"
className="relative min-h-screen flex items-center pt-16 overflow-hidden bg-linear-to-b from-[#FAFAFA] to-white"
>
<InkBackground />
<InkDecoration variant="balanced" />
<GradientFlow
colors={['#FAFAFA', '#FFE8EC', '#FFF0F3', '#F5F5F5', '#FFD6DD']}
duration={15}
/>
<SubtleDots color="#C41E3A" count={8} />
<div className="container-wide py-24 md:py-32 lg:py-40 relative z-10">
<div className="max-w-4xl mx-auto text-center">
@@ -83,6 +90,7 @@ export function HeroSection() {
</motion.div>
<motion.h1
id="hero-heading"
initial={{ opacity: 0, y: 20 }}
animate={isVisible ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6, delay: 0.1 }}
+2 -2
View File
@@ -20,7 +20,7 @@ export function ServicesSection() {
const isInView = useInView(ref, { once: true, margin: '-100px' });
return (
<section id="services" className="py-24 bg-white relative overflow-hidden" ref={ref}>
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
<div className="absolute top-1/3 left-0 w-[400px] h-[400px] bg-[rgba(79,70,229,0.03)] rounded-full blur-3xl" />
<div className="absolute top-1/3 right-0 w-[300px] h-[300px] bg-[rgba(196,30,58,0.02)] rounded-full blur-3xl" />
@@ -31,7 +31,7 @@ export function ServicesSection() {
transition={{ duration: 0.6 }}
className="text-center max-w-3xl mx-auto mb-16"
>
<h2 className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4">
<h2 id="services-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4">
<span className="text-[#C41E3A]"></span>
</h2>
<p className="text-lg text-[#5C5C5C] max-w-2xl mx-auto">
+6 -1
View File
@@ -10,6 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, id, ...props }, ref) => {
const generatedId = React.useId()
const inputId = id || generatedId
const errorId = `${inputId}-error`
return (
<div className="w-full">
@@ -34,11 +35,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className
)}
ref={ref}
aria-required={props.required ? "true" : undefined}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-[#C41E3A]">{error}</p>
<p id={errorId} className="mt-1 text-sm text-[#C41E3A]" role="alert">
{error}
</p>
)}
</div>
)
+28
View File
@@ -0,0 +1,28 @@
export function LoadingSkeleton({ className = '' }: { className?: string }) {
return (
<div className={`animate-pulse bg-gray-200 rounded ${className}`} />
);
}
export function FormSkeleton() {
return (
<div className="space-y-4">
<LoadingSkeleton className="h-12 w-full" />
<LoadingSkeleton className="h-12 w-full" />
<LoadingSkeleton className="h-12 w-full" />
<LoadingSkeleton className="h-32 w-full" />
</div>
);
}
export function SectionSkeleton() {
return (
<div className="py-24">
<div className="container-wide">
<LoadingSkeleton className="h-12 w-1/3 mb-8" />
<LoadingSkeleton className="h-6 w-2/3 mb-4" />
<LoadingSkeleton className="h-6 w-1/2" />
</div>
</div>
);
}
+6 -1
View File
@@ -10,6 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, id, ...props }, ref) => {
const generatedId = React.useId()
const textareaId = id || generatedId
const errorId = `${textareaId}-error`
return (
<div className="w-full">
@@ -33,11 +34,15 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
className
)}
ref={ref}
aria-required={props.required ? "true" : undefined}
aria-invalid={error ? "true" : "false"}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p className="mt-1 text-sm text-[#C41E3A]">{error}</p>
<p id={errorId} className="mt-1 text-sm text-[#C41E3A]" role="alert">
{error}
</p>
)}
</div>
)