Tab媒体组件 TabsWithMedia
组件分类: 新品页组件 | 适用场景: 产品功能展示、特性介绍、视频教程 | Figma: 查看设计稿
Tab媒体组件(TabsWithMedia) 是一个视频同步的 Tab 切换组件,支持视频时间轴自动切换Tab、点击Tab跳转视频时间、Swiper内容切换和Framer Motion动画效果。
核心功能: 视频时间轴同步、Tab自动切换、点击跳转视频、Swiper滑动切换、Framer Motion动画、响应式布局、多媒体支持
使用场景: 产品功能特性展示、新品介绍页、教学视频配合文字说明、多功能点展示
TabsWithMedia (Tab媒体组件)
带媒体内容的选项卡组件,支持图片和视频展示【✅ 已发布】
功能特性
TabsWithMedia 是一个高度交互的视频同步 Tab 组件,提供了丰富的功能来展示产品的多个特性:
核心特性
- ✅ 视频时间自动切换 - 根据视频播放时间自动高亮对应的 Tab
- ✅ 点击 Tab 跳转视频 - 点击 Tab 时视频跳转到对应时间点
- ✅ 轮播 Tab 显示 - 只显示 5 个 Tab,自动轮播切换
- ✅ Swiper 内容切换 - 使用 Swiper EffectFade 淡入淡出切换描述
- ✅ Framer Motion 动画 - Tab 项使用 motion.div 实现平滑进入/退出动画
- ✅ 响应式视频 - 支持桌面端和移动端不同视频源
- ✅ 自动播放循环 - 视频自动播放、静音、循环播放
- ✅ 主题切换 - 支持 light/dark 两种主题
- ✅ 完整 BEM 命名 - 提供完整的 CSS 类名便于自定义样式
- ✅ 曝光埋点 - 内置 useExposure 钩子支持数据埋点
Props参数
TabsWithMediaProps
| Prop | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
data | TabsWithMediaData | - | ✅ | 组件数据配置对象 |
className | string | - | - | 自定义 CSS 类名 |
TabsWithMediaData
| 字段 | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
title | string | - | ✅ | 主标题 |
video | Media | - | ✅ | 桌面端视频对象 |
items | TabItem[] | - | ✅ | Tab 项列表(建议 3-6 个) |
poster | Media | - | - | 默认视频封面图 |
mobvideo | Media | - | - | 移动端视频对象 |
timeIdx | TimeIndex[] | defaultTimeIdx | - | 时间点配置(视频自动切换 Tab) |
theme | 'light' | 'dark' | 'light' | - | 主题色 |
TabItem
| 字段 | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
tab | string | - | ✅ | Tab 标签文本 |
desc | string | - | ✅ | 描述文本 |
icon | Media | - | ✅ | Tab 图标对象 |
poster | Media | - | - | 视频封面图(可选) |
video | Media | - | - | 视频对象(可选) |
TimeIndex
| 字段 | 类型 | 默认值 | 必需 | 说明 |
|---|---|---|---|---|
time | number | - | ✅ | 该片段时长(秒) |
point | number | - | ✅ | 累计时间节点(秒) |
highlightIdx | number | - | ✅ | 高亮的 Tab 索引 |
Media 类型
interface Media {
url: string // 资源 URL
alt: string // 替代文本(必需)
thumbnailURL?: string // 缩略图 URL
mimeType?: string // MIME 类型
}类型定义
import type { Media, Theme } from '../../types/props'
export interface TabItem {
tab: string
desc: string
icon: Media
poster?: Media
tabIcon?: Media
video?: Media
}
export interface TimeIndex {
time: number // 片段时长
point: number // 时间节点
highlightIdx: number // 高亮索引
}
export interface TabsWithMediaProps {
data: {
title: string
poster?: Media
video: Media
mobvideo?: Media
items: TabItem[]
timeIdx?: TimeIndex[]
theme?: Theme
}
className?: string
}
// 默认时间点配置(6 个 Tab)
const defaultTimeIdx: TimeIndex[] = [
{ time: 2.4, point: 2.4, highlightIdx: 0 },
{ time: 2.5, point: 4.9, highlightIdx: 1 },
{ time: 2.5, point: 7.4, highlightIdx: 2 },
{ time: 2.8, point: 10.2, highlightIdx: 3 },
{ time: 2.6, point: 12.8, highlightIdx: 4 },
{ time: 3.2, point: 16, highlightIdx: 5 },
]使用示例
示例 1: 基础 3 项展示
展示最简单的 3 个功能特性,使用自定义时间索引,每个 Tab 分配 3 秒时间:
import { TabsWithMedia } from '@anker-in/headless-ui/biz'
export default function BasicDemo() {
return (
<TabsWithMedia
data={{
title: 'Three Key Features',
video: {
url: 'https://cdn.example.com/video.mp4',
alt: 'Product Features Video',
},
poster: {
url: 'https://cdn.example.com/poster.jpg',
alt: 'Video Poster',
},
items: [
{
tab: 'Smart AI',
desc: 'Intelligent navigation with obstacle avoidance.',
icon: {
url: 'https://cdn.example.com/icon-ai.png',
alt: 'AI Icon',
},
},
{
tab: 'Long Battery',
desc: 'Up to 180 minutes of continuous cleaning.',
icon: {
url: 'https://cdn.example.com/icon-battery.png',
alt: 'Battery Icon',
},
},
{
tab: 'Quiet Mode',
desc: 'Ultra-quiet operation at 55dB.',
icon: {
url: 'https://cdn.example.com/icon-quiet.png',
alt: 'Quiet Icon',
},
},
],
timeIdx: [
{ time: 3.0, point: 3.0, highlightIdx: 0 },
{ time: 3.0, point: 6.0, highlightIdx: 1 },
{ time: 3.0, point: 9.0, highlightIdx: 2 },
],
}}
/>
)
}示例 2: 完整 6 个特性展示(默认配置)
使用默认 timeIdx 配置展示 6 个 All-in-One 功能特性:
<TabsWithMedia
data={{
title: 'All-in-One Robot Vacuum',
video: {
url: 'https://cdn.shopify.com/videos/all-in-one.mp4',
alt: 'All-in-One Features',
},
mobvideo: {
url: 'https://cdn.shopify.com/videos/all-in-one-mobile.mp4',
alt: 'All-in-One Mobile',
},
poster: {
url: 'https://cdn.shopify.com/images/poster.png',
alt: 'Poster Image',
},
items: [
{
tab: 'Self-Emptying',
desc: '3L dust bag supports up to 75 days of maintenance-free cleaning.',
icon: { url: '/icons/emptying.png', alt: 'Self-Emptying' },
},
{
tab: 'Self-Refilling',
desc: 'Features a 2.5L clean water tank, sufficient for 4 mopping sessions.',
icon: { url: '/icons/refilling.png', alt: 'Self-Refilling' },
},
{
tab: 'Self-Washing',
desc: 'Automatically washes the mop after each session.',
icon: { url: '/icons/washing.png', alt: 'Self-Washing' },
},
{
tab: 'Hot Air Drying',
desc: 'Intelligent 35-50°C temperature control for efficient drying.',
icon: { url: '/icons/drying.png', alt: 'Hot Air Drying' },
},
{
tab: 'Wastewater Collection',
desc: '1.8L tank collects dirty water.',
icon: { url: '/icons/wastewater.png', alt: 'Wastewater' },
},
{
tab: 'Auto Detergent',
desc: 'Automatically dispenses detergent, lasting up to 6 months.',
icon: { url: '/icons/detergent.png', alt: 'Detergent' },
},
],
// 使用默认 timeIdx 配置
}}
/>示例 3: Dark 主题
使用深色主题展示组件,适合深色背景页面:
<TabsWithMedia
data={{
title: 'Premium Features',
video: { url: 'https://example.com/video.mp4', alt: 'Video' },
items: [
{
tab: 'Feature 1',
desc: 'Description 1',
icon: { url: '/icon1.png', alt: 'Icon 1' },
},
{
tab: 'Feature 2',
desc: 'Description 2',
icon: { url: '/icon2.png', alt: 'Icon 2' },
},
{
tab: 'Feature 3',
desc: 'Description 3',
icon: { url: '/icon3.png', alt: 'Icon 3' },
},
],
theme: 'dark', // 深色主题
}}
className="bg-gray-900" // 配合深色背景
/>示例 4: 自定义时间切换逻辑
根据内容重要性自定义时间分配,重要功能分配更长时间:
<TabsWithMedia
data={{
title: 'Custom Timing',
video: { url: 'https://example.com/custom-video.mp4', alt: 'Video' },
items: [
{ tab: 'Intro', desc: 'Introduction', icon: { url: '...', alt: 'Intro' } },
{ tab: 'Demo', desc: 'Product Demo', icon: { url: '...', alt: 'Demo' } },
{ tab: 'Specs', desc: 'Specifications', icon: { url: '...', alt: 'Specs' } },
{ tab: 'Summary', desc: 'Summary', icon: { url: '...', alt: 'Summary' } },
],
timeIdx: [
{ time: 5.0, point: 5.0, highlightIdx: 0 }, // Intro: 0-5s
{ time: 8.0, point: 13.0, highlightIdx: 1 }, // Demo: 5-13s (最重要)
{ time: 6.0, point: 19.0, highlightIdx: 2 }, // Specs: 13-19s
{ time: 4.0, point: 23.0, highlightIdx: 3 }, // Summary: 19-23s
],
}}
/>TimeIndex 计算方法:
// 方案 1: 平均分配(假设视频 20 秒, 4 个 Tab)
const timeIdx = [
{ time: 5.0, point: 5.0, highlightIdx: 0 }, // 0-5s
{ time: 5.0, point: 10.0, highlightIdx: 1 }, // 5-10s
{ time: 5.0, point: 15.0, highlightIdx: 2 }, // 10-15s
{ time: 5.0, point: 20.0, highlightIdx: 3 }, // 15-20s
]
// 方案 2: 根据内容重要性分配
const timeIdx = [
{ time: 3.0, point: 3.0, highlightIdx: 0 }, // Intro: 3s
{ time: 7.0, point: 10.0, highlightIdx: 1 }, // Main: 7s (重点)
{ time: 5.0, point: 15.0, highlightIdx: 2 }, // Feature: 5s
{ time: 5.0, point: 20.0, highlightIdx: 3 }, // Summary: 5s
]示例 5: 仅移动端视频
如果只在移动端显示不同视频,桌面端不传 mobvideo:
<TabsWithMedia
data={{
title: 'Mobile Optimized',
video: {
url: 'https://cdn.example.com/desktop-video.mp4',
alt: 'Desktop Video',
},
mobvideo: {
url: 'https://cdn.example.com/mobile-video.mp4',
alt: 'Mobile Video', // 移动端专用视频
},
items: [...],
}}
/>组件会根据屏幕宽度(768px)自动切换视频源。
示例 6: 禁用自动切换
不传 timeIdx,组件将只响应用户手动点击 Tab:
<TabsWithMedia
data={{
title: 'Manual Control Only',
video: { url: 'https://example.com/video.mp4', alt: 'Video' },
items: [
{ tab: 'Tab 1', desc: 'Description 1', icon: {...} },
{ tab: 'Tab 2', desc: 'Description 2', icon: {...} },
{ tab: 'Tab 3', desc: 'Description 3', icon: {...} },
],
// 不传 timeIdx,视频不会自动切换 Tab
}}
/>注意: 这种情况下,点击 Tab 仍然可以手动跳转视频时间点,但视频播放不会触发 Tab 切换。
示例 7: 自定义样式
使用自定义 className 和 CSS 变量自定义组件样式:
<TabsWithMedia
data={{
title: 'Custom Styled Tabs',
video: { url: 'https://example.com/video.mp4', alt: 'Video' },
items: [...],
}}
className="custom-tabs"
/>CSS 自定义:
/* 自定义标题颜色 */
.custom-tabs .tabs-with-media__title {
color: #ff6b6b;
font-weight: 700;
}
/* 自定义 Tab 图标背景 */
.custom-tabs .tabs-with-media__tab-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* 自定义活跃 Tab 样式 */
.custom-tabs .tabs-with-media__tab-content {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
border-radius: 12px;
}
/* 自定义描述文本 */
.custom-tabs .tabs-with-media__description-text {
font-size: 18px;
line-height: 1.8;
color: #333;
}
/* 自定义视频容器 */
.custom-tabs .tabs-with-media__video-wrapper {
border-radius: 24px;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}示例 8: 4 个 Tab 均衡分配
4 个 Tab,每个分配相同时间(4 秒):
<TabsWithMedia
data={{
title: 'Four Features',
video: { url: 'https://example.com/video-16s.mp4', alt: 'Video' },
items: [
{ tab: 'Power', desc: 'Powerful suction', icon: {...} },
{ tab: 'Smart', desc: 'Intelligent navigation', icon: {...} },
{ tab: 'Quiet', desc: 'Low noise operation', icon: {...} },
{ tab: 'Long', desc: 'Extended battery life', icon: {...} },
],
timeIdx: [
{ time: 4.0, point: 4.0, highlightIdx: 0 },
{ time: 4.0, point: 8.0, highlightIdx: 1 },
{ time: 4.0, point: 12.0, highlightIdx: 2 },
{ time: 4.0, point: 16.0, highlightIdx: 3 },
],
}}
/>示例 9: 使用 CSS 变量自定义渐变
通过 CSS 变量自定义 Tab 图标渐变背景:
:root {
--tab-gradient-start: #3AD1FF;
--tab-gradient-end: #008CD6;
}
.tabs-with-media__tab-icon {
background: linear-gradient(
90deg,
var(--tab-gradient-start) 0%,
var(--tab-gradient-end) 100%
);
}示例 10: 完整功能演示
结合所有功能的完整示例:
<TabsWithMedia
data={{
title: 'Complete Feature Showcase',
video: {
url: 'https://cdn.example.com/complete-demo.mp4',
alt: 'Complete Demo Video',
},
mobvideo: {
url: 'https://cdn.example.com/complete-demo-mobile.mp4',
alt: 'Complete Demo Mobile Video',
},
poster: {
url: 'https://cdn.example.com/poster.jpg',
alt: 'Video Poster',
},
items: [
{
tab: 'Feature A',
desc: 'Detailed description for Feature A with benefits and use cases.',
icon: {
url: 'https://cdn.example.com/icon-a.png',
alt: 'Feature A Icon',
},
},
{
tab: 'Feature B',
desc: 'Detailed description for Feature B with benefits and use cases.',
icon: {
url: 'https://cdn.example.com/icon-b.png',
alt: 'Feature B Icon',
},
},
{
tab: 'Feature C',
desc: 'Detailed description for Feature C with benefits and use cases.',
icon: {
url: 'https://cdn.example.com/icon-c.png',
alt: 'Feature C Icon',
},
},
{
tab: 'Feature D',
desc: 'Detailed description for Feature D with benefits and use cases.',
icon: {
url: 'https://cdn.example.com/icon-d.png',
alt: 'Feature D Icon',
},
},
{
tab: 'Feature E',
desc: 'Detailed description for Feature E with benefits and use cases.',
icon: {
url: 'https://cdn.example.com/icon-e.png',
alt: 'Feature E Icon',
},
},
],
timeIdx: [
{ time: 3.0, point: 3.0, highlightIdx: 0 },
{ time: 3.5, point: 6.5, highlightIdx: 1 },
{ time: 3.0, point: 9.5, highlightIdx: 2 },
{ time: 3.5, point: 13.0, highlightIdx: 3 },
{ time: 3.0, point: 16.0, highlightIdx: 4 },
],
theme: 'light',
}}
className="my-custom-tabs"
/>核心算法解析
算法 1: Tab 轮播算法
组件实现了只显示 5 个 Tab 的轮播效果,通过数组操作实现无限循环:
// 核心配置
const row = 5 // 显示 5 个 Tab
const center = 2 // 中心位置 index = 2
const listDouble = [...list, ...list] // 双倍列表实现无限循环
// 初始显示 0-4 索引的 Tab
const [cureentList, setCureentList] = useState(listDouble.slice(0, row))
// 向右滚动(点击右侧 Tab)
if (index > center) {
const gap = index - center // 计算滚动距离
const copy = [...cureentList]
copy.splice(0, gap) // 删除左侧 gap 个元素
const lastkey = cureentList[cureentList.length - 1].key + 1
const add = listDouble.splice(lastkey, gap) // 从右侧添加 gap 个元素
copy.push(...add)
setCureentList([...copy])
}
// 向左滚动(点击左侧 Tab)
if (index < center) {
const gap = center - index
const copy = [...cureentList]
copy.splice(-gap) // 删除右侧 gap 个元素
const firstkey = cureentList[0].key
const add = listDouble.splice(listLength + firstkey - gap, gap)
copy.unshift(...add) // 从左侧添加 gap 个元素
setCureentList([...copy])
}算法说明:
- 双倍列表: 将原列表复制一份拼接,实现循环滚动
- 固定中心: 始终保持 index=2 位置为活跃 Tab
- 动态替换: 根据点击位置计算需要替换的元素数量
- 无缝循环: 通过 key 值跟踪确保无限循环
示例场景:
原始列表: [A, B, C, D, E, F]
双倍列表: [A, B, C, D, E, F, A, B, C, D, E, F]
初始显示(0-4): [A, B, C*, D, E] (* 表示活跃 Tab)
点击 D(index=3): [B, C, D*, E, F]
点击 E(index=4): [C, D, E*, F, A]
点击 A(index=0): [D, C, A*, B, C]算法 2: 视频时间同步算法
根据视频播放时间自动切换 Tab:
const handleTimeUpdate = () => {
const currentTime = video.currentTime
// 遍历 timeIdx 找到当前时间对应的 Tab
for (let i = 0; i < timeIdx.length; i++) {
const prev = i === 0 ? 0 : timeIdx[i - 1].point
if (currentTime >= prev && currentTime < timeIdx[i].point) {
// isAuto=true 表示由视频触发,不再跳转视频时间
handleNavClick(timeIdx[i].highlightIdx, timeIdx[i].highlightIdx, true)
break
}
}
// 视频播放完自动重播
if (currentTime >= timeIdx[timeIdx.length - 1].point) {
video.currentTime = 0
video.play()
}
}
video.addEventListener('timeupdate', handleTimeUpdate)算法说明:
- 监听 timeupdate: 视频播放时持续监听时间更新
- 区间匹配: 找到当前时间落在哪个 timeIdx 区间
- 自动切换: 调用 handleNavClick 切换 Tab(isAuto=true 避免递归)
- 循环播放: 播放完毕后重置到 0 秒并重新播放
时间区间计算:
timeIdx = [
{ time: 2.4, point: 2.4, highlightIdx: 0 }, // 区间: [0, 2.4)
{ time: 2.5, point: 4.9, highlightIdx: 1 }, // 区间: [2.4, 4.9)
{ time: 2.5, point: 7.4, highlightIdx: 2 }, // 区间: [4.9, 7.4)
]
currentTime = 3.0 秒
→ 落在区间 [2.4, 4.9)
→ 高亮 highlightIdx = 1 (第 2 个 Tab)算法 3: 点击 Tab 跳转视频算法
点击 Tab 时计算累计时间并跳转视频:
const handleNavClick = (key: number, index: number, isAuto?: boolean) => {
// 更新 Tab 轮播显示
setIdx(key)
swiper?.slideTo(key) // 切换 Swiper 描述
// 如果是手动点击(非视频自动触发)
if (!isAuto) {
let time = 0
// 计算累计时间
timeIdx.forEach(item => {
if (item.highlightIdx < key) {
time += item.time
}
})
// 跳转到对应时间点
if (videoRef.current) {
videoRef.current.currentTime = time
}
}
}算法说明:
- isAuto 判断: 区分手动点击和视频自动触发
- 累计时间: 累加目标 Tab 之前所有片段的时长
- 视频跳转: 设置 video.currentTime 跳转到计算的时间点
- Swiper 同步: 同步切换 Swiper 显示对应描述
跳转时间计算示例:
timeIdx = [
{ time: 2.4, point: 2.4, highlightIdx: 0 },
{ time: 2.5, point: 4.9, highlightIdx: 1 },
{ time: 2.5, point: 7.4, highlightIdx: 2 },
]
// 点击第 3 个 Tab (highlightIdx=2)
time = 0
item[0]: highlightIdx=0 < 2 → time += 2.4 = 2.4
item[1]: highlightIdx=1 < 2 → time += 2.5 = 4.9
item[2]: highlightIdx=2 不小于 2 → 跳过
→ 视频跳转到 4.9 秒BEM 类结构
组件使用完整的 BEM 命名规范,便于自定义样式:
根元素
.tabs-with-media- 组件根容器
标题区域
.tabs-with-media__title- 主标题
Tab 区域
.tabs-with-media__tabs-wrapper- Tab 外层包装器(处理滚动).tabs-with-media__tabs-inner- Tab 内层容器.tabs-with-media__tabs-container- Tab 容器(包含所有 Tab 项).tabs-with-media__tab-item- 单个 Tab 项(motion.div).tabs-with-media__tab-inner- Tab 项内层容器.tabs-with-media__tab-content- Tab 项内容容器.tabs-with-media__tab-icon- Tab 图标容器.tabs-with-media__tab-icon-image- Tab 图标图片.tabs-with-media__tab-text-wrapper- Tab 文本包装器.tabs-with-media__tab-text- Tab 文本(动态宽度)
描述区域
.tabs-with-media__description-container- 描述区域外层容器.tabs-with-media__description-swiper- 描述 Swiper 实例.tabs-with-media__description-slide- 描述滑块.tabs-with-media__description-content- 描述内容容器.tabs-with-media__description-text- 描述文本
视频区域
.tabs-with-media__video-wrapper- 视频外层容器.tabs-with-media__video- 视频元素
响应式行为
断点说明
组件在不同断点下的行为变化:
| 断点 | 范围 | Tab 图标尺寸 | Tab 文本 | 视频高度 | Gap |
|---|---|---|---|---|---|
| Mobile | 0-767px | 28px | 动态显示 | 360px | 16px |
| Tablet | 768-1024px | 28px | 动态显示 | 360px | 8px |
| Laptop | 1025-1439px | 28px | 动态显示 | 360px | 8px |
| Desktop | 1440-1919px | 28px | 动态显示 | 448px | 8px |
| LG Desktop | ≥1920px | 48px | 动态显示 | 560px | 8px |
响应式视频切换
const isMob = useMediaQuery({ query: '(max-width: 768px)' })
<video
src={isMob ? mobvideo?.url : video?.url} // 移动端使用 mobvideo
poster={poster?.url}
playsInline
autoPlay
muted
loop
/>说明:
- 桌面端(≥768px): 使用
video.url - 移动端(<768px): 优先使用
mobvideo.url,如果未提供则回退到video.url playsInline: iOS Safari 内联播放muted: Chrome 要求静音才能自动播放loop: 自动循环播放
Tab 文本动态宽度
Tab 文本使用 CSS calc-size(auto, size) 实现动态宽度展开效果:
<Heading
className={cn(
'tabs-with-media__tab-text size-0 opacity-0',
{
'h-auto w-[calc-size(auto,size)] px-[6px] opacity-100': idx === item?.key,
// 仅活跃 Tab 显示文本,其他 Tab 隐藏
}
)}
/>CSS 动画效果:
.tabs-with-media__tab-text {
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
transition: all 0.3s ease-linear;
}
.tabs-with-media__tab-text.active {
width: calc-size(auto, size); /* 自适应宽度 */
height: auto;
padding: 0 6px;
opacity: 1;
}响应式布局示意图
┌─────────────────────────────────────────────────────────────┐
│ Mobile (0-767px) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Title (Heading Size 4) │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌──────┬──────┬──────┬──────┬──────┐ Gap: 16px │
│ │ Tab1 │ Tab2 │ Tab3*│ Tab4 │ Tab5 │ Icon: 28px │
│ └──────┴──────┴──────┴──────┴──────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Description (Swiper) │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Video (360px height) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Desktop (≥1440px) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Title (Heading Size 4) │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌──────┬──────┬──────┬──────┬──────┐ Gap: 8px │
│ │ Tab1 │ Tab2 │ Tab3*│ Tab4 │ Tab5 │ Icon: 28px │
│ └──────┴──────┴──────┴──────┴──────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Description (Swiper) │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Video (448px height) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ LG Desktop (≥1920px) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Title (Heading Size 4) │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌──────┬──────┬──────┬──────┬──────┐ Gap: 8px │
│ │ Tab1 │ Tab2 │ Tab3*│ Tab4 │ Tab5 │ Icon: 48px (larger)│
│ └──────┴──────┴──────┴──────┴──────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Description (Swiper) │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Video (560px height) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘设计规范
使用建议
- Tab 数量: 建议 3-6 个 Tab,过多会影响用户体验
- 视频时长: 建议 15-25 秒,每个 Tab 分配 2-5 秒
- 图标设计: 图标建议 48x48px PNG 透明背景,简洁明了
- 文案长度: Tab 文本建议 8-15 个字符,描述文本建议 60-100 个字符
- 视频格式: 推荐 MP4 H.264 编码,码率 2-5 Mbps
最佳实践
// ✅ 推荐: 6 个 Tab,均衡分配时间
const timeIdx = [
{ time: 2.4, point: 2.4, highlightIdx: 0 },
{ time: 2.5, point: 4.9, highlightIdx: 1 },
{ time: 2.5, point: 7.4, highlightIdx: 2 },
{ time: 2.8, point: 10.2, highlightIdx: 3 },
{ time: 2.6, point: 12.8, highlightIdx: 4 },
{ time: 3.2, point: 16, highlightIdx: 5 },
]
// ❌ 不推荐: 时间分配不均
const timeIdx = [
{ time: 1.0, point: 1.0, highlightIdx: 0 }, // 太短
{ time: 8.0, point: 9.0, highlightIdx: 1 }, // 太长
{ time: 1.5, point: 10.5, highlightIdx: 2 }, // 太短
]视频制作建议
- 时长控制: 每个 Tab 对应的视频片段建议 2-4 秒
- 转场设计: 视频片段之间使用淡入淡出转场
- 节奏把控: 视频节奏与 Tab 切换保持同步
- 画面构图: 确保每个片段的主体清晰可见
- 字幕辅助: 建议在视频中添加字幕辅助理解
颜色规范
Light 主题 (默认):
- 标题:
text-gray-900 - Tab 文本:
text-gray-700 - 描述文本:
text-gray-600 - Tab 图标背景(活跃):
linear-gradient(90deg, #3AD1FF 0%, #008CD6 100%) - Tab 图标背景(非活跃): 透明
Dark 主题:
- 标题:
text-gray-100 - Tab 文本:
text-gray-200 - 描述文本:
text-gray-300 - Tab 图标背景(活跃):
linear-gradient(90deg, #3AD1FF 0%, #008CD6 100%) - Tab 图标背景(非活跃): 透明
无障碍性
ARIA 标签
组件实现了完整的 ARIA 标签以支持屏幕阅读器:
<section
ref={boxRef}
data-ui-component-id="TabsWithMedia"
role="region"
aria-label={title}
>
<Heading as="h3" size={4} html={title} id="tabs-title" />
<div
role="tablist"
aria-labelledby="tabs-title"
>
{items.map((item, index) => (
<div
role="tab"
aria-selected={idx === index}
aria-controls={`panel-${index}`}
tabIndex={idx === index ? 0 : -1}
onClick={() => handleNavClick(index, index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleNavClick(index, index)
}
}}
>
{item.tab}
</div>
))}
</div>
<div
role="tabpanel"
id={`panel-${idx}`}
aria-labelledby={`tab-${idx}`}
>
<video
ref={videoRef}
aria-label={`${title} demonstration video`}
>
<track
kind="captions"
src="/captions.vtt"
srcLang="en"
label="English"
/>
</video>
</div>
</section>键盘导航
| 键 | 操作 |
|---|---|
Tab | 在 Tab 项之间导航 |
Enter / Space | 激活当前 Tab 并跳转视频 |
Arrow Left | 切换到上一个 Tab(建议实现) |
Arrow Right | 切换到下一个 Tab(建议实现) |
Home | 跳转到第一个 Tab(建议实现) |
End | 跳转到最后一个 Tab(建议实现) |
屏幕阅读器支持
- Tab 角色: 每个 Tab 项使用
role="tab"和aria-selected属性 - Tab 面板: 描述区域使用
role="tabpanel"和对应的aria-labelledby - 视频标签: 视频元素使用
aria-label说明内容 - 字幕支持: 建议添加
<track>元素提供字幕
焦点管理
// 自动管理焦点,活跃 Tab 可聚焦,其他不可聚焦
<div
tabIndex={idx === index ? 0 : -1}
role="tab"
aria-selected={idx === index}
>
{item.tab}
</div>性能优化
React 优化
// 使用 useMemo 缓存列表计算
const [list, listDouble, row, center, listLength] = useMemo(() => {
const row = 5
const center = 2
const listLength = items.length
const list = items?.map((item, index) => ({
...item,
key: index,
index: index,
})) || []
const listSecond = items?.map((item, index) => ({
...item,
key: index + listLength,
index: index,
})) || []
return [list, list.concat(listSecond), row, center, listLength]
}, [items, isMob])
// 使用 React.memo 包裹子组件
const TabItem = React.memo(({ item, isActive, onClick }) => (
<motion.div onClick={onClick}>
{/* Tab 内容 */}
</motion.div>
))视频优化
// 1. 视频预加载
<video
preload="metadata" // 只预加载元数据,不预加载视频内容
playsInline // 移动端内联播放
muted // 静音自动播放(Chrome 要求)
/>
// 2. 响应式视频源
const isMob = useMediaQuery({ query: '(max-width: 768px)' })
const videoSrc = isMob ? mobvideo?.url : video?.url
// 3. 视频格式优化
// - 使用 MP4 + H.264 编码
// - 码率: 2-5 Mbps
// - 分辨率: 桌面 1920x1080, 移动 720p
// - 压缩: 使用 FFmpeg 优化Framer Motion 优化
// 使用 AnimatePresence 的 mode="popLayout"
<AnimatePresence mode="popLayout">
{cureentList?.map((item) => (
<motion.div
layout // 自动布局动画
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3, // 短动画时长
ease: 'easeOut',
}}
>
{/* Tab 内容 */}
</motion.div>
))}
</AnimatePresence>Swiper 优化
// 使用 EffectFade 代替 Slide
<Swiper
effect="fade"
fadeEffect={{ crossFade: true }} // 交叉淡入淡出
modules={[Pagination, EffectFade]} // 只引入需要的模块
slidesPerView={1}
/>性能监控
// 使用 useExposure 钩子监控曝光
import { useExposure } from '@/hooks/useExposure'
const TabsWithMedia = ({ data }) => {
const boxRef = useRef(null)
useExposure({
ref: boxRef,
onExposure: () => {
// 曝光埋点逻辑
console.log('TabsWithMedia exposed')
},
})
return <section ref={boxRef}>...</section>
}常见问题 FAQ
1. 如何自定义 Tab 数量和时间分配?
答: 通过 timeIdx 配置自定义时间点:
// 假设 4 个 Tab,视频总时长 15 秒
<TabsWithMedia
data={{
items: [
{ tab: 'Tab 1', desc: '...', icon: {...} },
{ tab: 'Tab 2', desc: '...', icon: {...} },
{ tab: 'Tab 3', desc: '...', icon: {...} },
{ tab: 'Tab 4', desc: '...', icon: {...} },
],
timeIdx: [
{ time: 3.0, point: 3.0, highlightIdx: 0 }, // Tab 1: 0-3s
{ time: 4.0, point: 7.0, highlightIdx: 1 }, // Tab 2: 3-7s
{ time: 5.0, point: 12.0, highlightIdx: 2 }, // Tab 3: 7-12s
{ time: 3.0, point: 15.0, highlightIdx: 3 }, // Tab 4: 12-15s
],
}}
/>计算公式:
time: 当前片段时长point: 累计到当前的总时长 = 前面所有time之和 + 当前timehighlightIdx: Tab 索引(从 0 开始)
2. 为什么只显示 5 个 Tab,如何修改?
答: 组件设计为同时显示 5 个 Tab 以优化视觉效果。如需修改,可以调整源码中的 row 常量:
// 在 TabsWithMedia.tsx 中
const [list, listDouble, row, center, listLength] = useMemo(() => {
const row = 7 // 修改为 7,同时显示 7 个 Tab
const center = 3 // 中心位置改为 3 (即第 4 个 Tab)
// ...
}, [items, isMob])注意: 修改后需要同步调整 center 值,建议 center = Math.floor(row / 2)。
3. 视频播放到最后会怎样?
答: 组件会自动检测视频播放到最后一个时间点后,重置到 0 秒并重新播放:
// 自动循环播放逻辑
if (currentTime >= timeIdx[timeIdx.length - 1].point) {
video.currentTime = 0
video.play()
}4. 如何禁用视频自动切换 Tab?
答: 移除 timeIdx 配置,组件将只响应用户点击:
<TabsWithMedia
data={{
title: 'Manual Control Only',
video: { url: '...', alt: '...' },
items: [...],
// 不传 timeIdx,视频不会自动切换 Tab
}}
/>注意: 这种情况下,点击 Tab 仍然可以手动跳转视频时间点,但视频播放不会触发 Tab 切换。
5. 移动端和桌面端可以使用不同视频吗?
答: 可以,通过 video 和 mobvideo 配置:
<TabsWithMedia
data={{
video: {
url: 'https://cdn.example.com/desktop-video.mp4', // 桌面端视频
alt: 'Desktop Video',
},
mobvideo: {
url: 'https://cdn.example.com/mobile-video.mp4', // 移动端视频
alt: 'Mobile Video',
},
items: [...],
}}
/>组件会根据屏幕宽度(768px)自动切换视频源。
6. Tab 文本如何实现动态宽度效果?
答: 使用 CSS calc-size(auto, size) 和条件类名:
.tabs-with-media__tab-text {
/* 初始状态: 宽度为 0,透明 */
width: 0;
height: 0;
opacity: 0;
overflow: hidden;
transition: all 0.3s ease-linear;
}
/* 活跃状态: 宽度自动,不透明 */
.tabs-with-media__tab-text.active {
width: calc-size(auto, size);
height: auto;
padding: 0 6px;
opacity: 1;
}7. 如何自定义 Tab 图标的渐变背景?
答: 修改 inline style 中的 background 属性:
<div
style={
idx === item?.key
? {
// 自定义渐变背景
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}
: undefined
}
>
<Picture source={item?.icon?.url} alt={item?.icon?.alt} />
</div>或者通过 CSS 变量:
:root {
--tab-gradient: linear-gradient(90deg, #3AD1FF 0%, #008CD6 100%);
}
.tabs-with-media__tab-icon {
background: var(--tab-gradient);
}8. Swiper 淡入淡出效果如何配置?
答: 组件使用 effect="fade" 和 EffectFade 模块:
import { Swiper, SwiperSlide } from 'swiper/react'
import { Pagination, EffectFade } from 'swiper/modules'
<Swiper
slidesPerView={1}
effect="fade"
fadeEffect={{ crossFade: true }} // 交叉淡入淡出
modules={[Pagination, EffectFade]}
onSlideChange={(swiper) => {
setIdx(swiper.realIndex)
}}
>
{items.map((item, index) => (
<SwiperSlide key={index}>
<Text html={item.desc} />
</SwiperSlide>
))}
</Swiper>9. 如何优化视频加载性能?
答: 采用以下优化策略:
- 预加载优化: 使用
preload="metadata"只加载元数据 - 响应式视频: 移动端使用较小分辨率视频
- 视频格式: 使用 MP4 + H.264 编码,码率 2-5 Mbps
- 视频压缩: 使用 FFmpeg 压缩视频文件
- CDN 加速: 将视频托管在 CDN 上
# 使用 FFmpeg 压缩视频
ffmpeg -i input.mp4 -c:v libx264 -crf 23 -preset medium -c:a aac -b:a 128k output.mp410. Tab 轮播动画如何自定义?
答: 通过修改 Framer Motion 的 transition 配置:
<motion.div
layout
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.5, // 修改动画时长
ease: 'easeInOut', // 修改缓动函数
delay: 0.1, // 添加延迟
}}
>
{/* Tab 内容 */}
</motion.div>相关资源
设计资源
- Figma 设计稿: IPC-TO-C-Agent
- Design Token 文档: https://headless-ui-doc.netlify.app/
代码资源
- 源代码:
packages/ui/src/biz-components/TabsWithMedia/ - 类型定义:
packages/ui/src/biz-components/TabsWithMedia/types.ts - Story 文件:
packages/ui/src/stories/tabsWithMedia.stories.tsx - 单元测试:
packages/ui/tests/TabsWithMedia.test.tsx
依赖组件
Heading- 标题组件 (@anker-in/headless-ui)Text- 文本组件 (@anker-in/headless-ui)Picture- 图片组件 (@anker-in/headless-ui)
外部依赖
framer-motion- 动画库 (v10.0+)swiper- 轮播库 (v11.0+)react-responsive- 响应式判断 (v9.0+)
相关文档
Changelog
v1.0.0 (2026-01-16)
首次发布
- ✅ 视频时间自动切换 Tab
- ✅ 点击 Tab 跳转视频
- ✅ 轮播 Tab 显示(5 个)
- ✅ Swiper EffectFade 切换
- ✅ Framer Motion 动画
- ✅ 响应式视频(桌面/移动端)
- ✅ 主题切换(light/dark)
- ✅ 完整 BEM 命名
- ✅ TypeScript 严格模式
- ✅ 曝光埋点支持
已知问题:
- 视频自动播放在某些移动浏览器可能受限(需用户交互)
- Figma 设计稿不完整但可用
待优化项:
- 支持自定义 Tab 显示数量(目前固定 5 个)
- 支持键盘方向键切换 Tab
- 支持 Home/End 键跳转首尾 Tab