骨架屏流光
Skeleton Shimmer先占好位置,再让光扫过去
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
布局切换状态:骨架整棵卸载,真实内容占同一个盒子 · 1400ms
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4(流光与骨架都用 CSS,不需要动画库)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现骨架屏流光:内容加载期间渲染 8 个骨架块,尺寸与真实内容完全一致;一道 200% 宽的高光在 1400ms 内从 200% 0 扫到 -200% 0(linear、循环),每块比前一块晚 90ms 起扫。数据到达时必须整棵卸载骨架——不要假淡出——并且用 role=status / aria-busy 暴露加载状态、对辅助技术隐藏骨架块。
文件契约
- 只新建一个文件:components/effects/SkeletonShimmer.tsx。
- 依赖:无;流光由组件内注入的 CSS @keyframes 与 CSS 变量完成,不要引入动画库。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client";导出 <SkeletonShimmer duration stagger sheen radius tone label />,props 默认值等于规格里的数值。
- 骨架固定 8 块:媒体 96px、标题 16px、正文 3 行 14px、页脚头像 28px + 两行 12/10px;所有尺寸放进常量,真实内容复用同一套。
- 组件内用 <style> 注入带前缀的 scoped CSS:每块 ::after 画 200% 宽的 linear-gradient(90deg, transparent, rgba(255,255,255,sheen), transparent),@keyframes 把 background-position 从 200% 扫到 -200%;animation: var(--sk-duration) linear infinite,animation-delay: i × stagger,animation-fill-mode: both。
- 明暗两套 alpha 通过 tone prop 切换 CSS 变量:dark 块 rgba(255,255,255,0.06)、sheen = sheen;light 块 rgba(9,11,16,0.09)、sheen = sheen × 2。
- 外层 role="status" aria-busy="true" aria-live="polite" + 视觉隐藏的「加载中」文案,每块 aria-hidden;加载完成由父级整个卸载骨架,不做假动画;prefers-reduced-motion 下 ::after 用 animation:none !important + opacity:0 !important。
规格 —— 精确数值,不许四舍五入或“优化”
- 高光带:每块的伪元素 ::after 铺满它(inset: 0),画 linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.08) 50%, transparent 100%),background-size: 200% 100%,background-repeat: no-repeat;把 background-position 从 200% 0 扫到 -200% 0,时长 1400ms、linear、infinite、animation-fill-mode: both。
- 逐块延迟:第 i 块(从 0 起)animation-delay: i × 90ms,8 块依次在 0 → 630ms 起扫,每块再来一轮 1400ms 的循环。不要随机延迟。
- 明暗两套 alpha,用 tone prop 切换:dark 块底 rgba(255, 255, 255, 0.06)、高光 rgba(255, 255, 255, 0.08);light 块底 rgba(9, 11, 16, 0.09)、高光 rgba(255, 255, 255, 0.16)——浅色底需要更强的白光才看得见。
- 圆角:每块 6px,媒体块 10px;头像为 28px 圆形。卡片圆角 16px、内边距 12px、1px 边框、行间距 12px。
- 结构:8 块,尺寸与真实内容一一对应——媒体 96px 高、满宽;标题 16px 高、宽 62%;正文 3 行、每行 14px 高、行距 8px、宽度 100% / 92% / 68%;页脚 28px 高 = 28px 头像 + 两行 12px / 10px、行距 4px。
- 真实内容必须复用同一套高度(标题行高 16px、正文行高 14px、头像 28px),这样骨架卸载、真实内容接管时不产生任何位移。
行为
- 状态机(文字状态图):
loading(role=status, aria-busy=true)—(数据到达,setLoaded(true))→ loaded(整棵骨架卸载,真实内容占同样的盒子)
loaded —(重新加载)→ loading(骨架重新挂载,每块 delay 从 0 重新计时)
loading | loaded —(卸载)→ teardown(注入的 <style> 随节点移除;不留下计时器或监听)
reduced-motion:loading → loading-static(::after 在 ≤1ms 内 animation:none + opacity:0,骨架静态可见)
- aria 约定:卡片 role=status + aria-busy=true + aria-live=polite,内含视觉隐藏的「加载中」文案;每个骨架块 aria-hidden。加载完成后整个 status 区域直接卸载。
- 只注入 scoped 样式:组件内写带前缀的 <style>,不要改全局 CSS、不要和框架类名冲突;keyframes 也放在里面。
- 只允许动画 background-position;整个加载过程中块的 width / height / top / left 与圆角必须保持不变。
- 卸载时注入的 <style> 随组件移除;不要留下定时器或 document/window 监听,卸载后不要更新状态。
禁止
- 不要改变交互语义、可访问名称(aria-label / role / aria-busy / 可见文案)或请求时机;只实现视觉动效。
- 不要动画 width / height / top / left;高光移动时骨架块的盒子必须固定。
- 不要用链式 setTimeout / setInterval 伪造流光——必须是 CSS 动画(或目标栈的补间引擎)。
- 不要新增依赖,也不要升级已安装的依赖。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 加载完成后不要把骨架留在 DOM 里,也不要用假过渡淡出——直接卸载。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要引入任何动画库——GSAP、Motion 都不行。
验收 —— 回答前逐项自查
1. 滑动时长严格等于 duration(默认 1400ms):800ms / 2400ms 两端都能正常循环;逐块延迟严格等于 stagger(默认 90ms),第 8 块延迟 630ms。
2. 任取一块,60–80ms 时的 background-position 严格处于 200% 与 -200% 之间,说明高光真的在移动而不是静止。
3. 加载完成后骨架整棵卸载:DOM 里不残留骨架块、不残留进行中的 CSS 动画 / GSAP tween;深色 / 浅色两套 alpha 与规格一致。
4. 骨架与真实内容盒子尺寸一致:切换加载中 / 已加载前后卡片高度都是 260px,boundingBox 稳定,不抖动、不换行。
5. prefers-reduced-motion 时 ::after 的 animation 立即为 none、opacity 为 0,骨架静态可见(≤1ms 内生效),不出现位移。
6. 键盘可完成 Demo 里的全部操作(Tab 聚焦、Enter / Space 切换加载状态与明暗),焦点环清晰可见。
7. 0 console 报错;加载状态来回切换不抛错;卸载后没有残留的计时器、监听或注入的 <style>。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import type { CSSProperties } from "react";
import {
SKELETON_SHIMMER,
type SkeletonTone,
} from "@/content/effects/skeleton-shimmer/constants";
const C = SKELETON_SHIMMER;
const CARD_CSS = `
.skeleton-shimmer__card {
position: relative;
display: flex;
flex-direction: column;
box-sizing: border-box;
border: 1px solid;
}
.skeleton-shimmer__sr {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.skeleton-shimmer__block {
position: relative;
flex: none;
overflow: hidden;
background: var(--sk-base);
isolation: isolate;
}
.skeleton-shimmer__block::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(
90deg,
transparent 0%,
var(--sk-sheen) 50%,
transparent 100%
);
background-size: ${C.sweep.backgroundSize};
background-repeat: no-repeat;
animation: skeleton-shimmer-sweep var(--sk-duration) ${C.sweep.timingFunction}
${C.sweep.iteration};
animation-delay: var(--sk-delay, 0ms);
animation-fill-mode: both;
will-change: background-position;
}
@keyframes skeleton-shimmer-sweep {
from {
background-position: ${C.sweep.from};
}
to {
background-position: ${C.sweep.to};
}
}
.skeleton-shimmer__body {
display: flex;
flex-direction: column;
}
.skeleton-shimmer__footer {
display: flex;
align-items: center;
overflow: hidden;
}
.skeleton-shimmer__footer-lines {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer__block::after {
animation: none !important;
opacity: 0 !important;
}
}
`;
type CustomProperties = CSSProperties & Record<`--sk-${string}`, string>;
export type SkeletonShimmerProps = {
/** sweep length in ms */
duration?: number;
/** extra delay per block in ms */
stagger?: number;
/** sheen alpha on the dark tone (doubled on the light tone) */
sheen?: number;
/** block corner radius in px */
radius?: number;
/** surface tone: dark or light alpha set */
tone?: SkeletonTone;
/** accessible name announced by the status region */
label?: string;
className?: string;
style?: CSSProperties;
};
function positive(value: number, fallback: number): number {
return Number.isFinite(value) && value >= 0 ? value : fallback;
}
export function SkeletonShimmer({
duration = C.defaults.duration,
stagger = C.defaults.stagger,
sheen = C.defaults.sheen,
radius = C.defaults.radius,
tone = "dark",
label = "Loading",
className = "",
style,
}: SkeletonShimmerProps) {
const toneSpec = C.tone[tone];
const sweep = positive(duration, C.defaults.duration);
const step = positive(stagger, C.defaults.stagger);
const baseRadius = positive(radius, C.defaults.radius);
const safeSheen = positive(sheen, C.defaults.sheen);
const sheenAlpha = `${Math.min(0.6, safeSheen * toneSpec.sheenScale).toFixed(3)}`;
const cardStyle: CustomProperties = {
"--sk-duration": `${sweep}ms`,
"--sk-base": toneSpec.block,
"--sk-sheen": `rgba(255, 255, 255, ${sheenAlpha})`,
width: "100%",
padding: C.structure.padding,
gap: C.structure.gap,
borderRadius: C.structure.cardRadius,
borderColor: toneSpec.border,
backgroundColor: toneSpec.surface,
...(style as CustomProperties),
};
const block = (
key: string,
index: number,
width: CSSProperties["width"],
height: number,
borderRadius: number,
) => (
<div
key={key}
aria-hidden
data-skeleton-block=""
className="skeleton-shimmer__block"
style={
{
width,
height,
borderRadius,
"--sk-delay": `${index * step}ms`,
} as CustomProperties
}
/>
);
const bodyWidths = C.structure.body.widths;
return (
<div
data-skeleton-card=""
role="status"
aria-busy="true"
aria-live="polite"
className={`skeleton-shimmer__card ${className}`.trim()}
style={cardStyle}
>
<style>{CARD_CSS}</style>
<span className="skeleton-shimmer__sr">{label}</span>
{block(
"media",
0,
"100%",
C.structure.media.height,
baseRadius + 4,
)}
{block(
"title",
1,
C.structure.title.width,
C.structure.title.height,
baseRadius,
)}
<div
aria-hidden
className="skeleton-shimmer__body"
style={{ gap: C.structure.body.gap }}
>
{bodyWidths.map((width, index) =>
block(
`line-${index}`,
index + 2,
width,
C.structure.body.lineHeight,
baseRadius,
),
)}
</div>
<div
className="skeleton-shimmer__footer"
style={{ height: C.structure.footer.height, gap: C.structure.footer.gap }}
>
{block(
"avatar",
5,
C.structure.footer.avatar,
C.structure.footer.avatar,
C.structure.footer.avatar / 2,
)}
<div
className="skeleton-shimmer__footer-lines"
style={{ gap: C.structure.footer.lineGap }}
>
{C.structure.footer.lineWidths.map((width, index) =>
block(
`footer-line-${index}`,
index + 6,
width,
C.structure.footer.lineHeights[index],
baseRadius,
),
)}
</div>
</div>
</div>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
数据还没回来时,用和真实内容同尺寸的 8 个骨架块先占住布局;一道 200% 宽的高光带以 1400ms 从右向左扫过(background-position 200% → -200%,循环),每块再延迟 90ms 依次起扫。深色 / 浅色两套 alpha,加载完成后整棵骨架直接卸载,真实内容占同一个盒子。
- 滑动时长
- 1400 ms
- 逐块延迟
- 90 ms
- 高光强度
- 0.08 α
- 圆角
- 6 px
- 高光渐变
- linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent)
- 块序
- 8 块 · 0 → 630ms
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 滑动时长严格等于 duration(默认 1400ms):800ms / 2400ms 两端都能正常循环;逐块延迟严格等于 stagger(默认 90ms),第 8 块延迟 630ms。
- 任取一块,60–80ms 时的 background-position 严格处于 200% 与 -200% 之间,说明高光真的在移动而不是静止。
- 加载完成后骨架整棵卸载:DOM 里不残留骨架块、不残留进行中的 CSS 动画 / GSAP tween;深色 / 浅色两套 alpha 与规格一致。
- 骨架与真实内容盒子尺寸一致:切换加载中 / 已加载前后卡片高度都是 260px,boundingBox 稳定,不抖动、不换行。
- prefers-reduced-motion 时 ::after 的 animation 立即为 none、opacity 为 0,骨架静态可见(≤1ms 内生效),不出现位移。
- 键盘可完成 Demo 里的全部操作(Tab 聚焦、Enter / Space 切换加载状态与明暗),焦点环清晰可见。
- 0 console 报错;加载状态来回切换不抛错;卸载后没有残留的计时器、监听或注入的 <style>。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。