跳到正文
册页

反馈

Skeleton - 骨架屏

页面到来之前,页面的形状。

用法

什么时候用它

长到读者会以为页面坏了的等待。描述“接下来是什么”的形状,胜过什么都不描述的一个点。
TSX
import { Skeleton } from '@misoto22/folio'

说明

所有骨架部件的填充色都来自这里:--stone,高度默认是一行文字,直到有别的东西另行指定。

刻意不做流光。流光会把一道高光扫过形状,那意味着有光源;归白没有光源,所以这一扫读起来像 bug 而不像加载中。取而代之的是整页一起呼吸——见 SkeletonPage,那一次脉动归它管。

h-3 是一个默认值,不是一个设计。一个 <div> 本来就是满宽的,所以高度是调用方唯一可以省掉、然后一无所获的那个维度——一个光秃秃的 <Skeleton /> 是个零高度的盒子,那不是一个小骨架,那是一个不存在的骨架。12 像素是 SkeletonLine 本来就选定的高度,而调用方传的任何类名都会把它替掉。

结构

Skeleton anatomy
部件说明
外框必填SkeletonPage:带着 role="status"、aria-busy="true" 和那唯一一次脉冲的 div。它是唯一会说话、也是唯一会动的部分——里面的形状两样都不做。
标签必填外框里那句 sr-only 的话,来自必填的 label。每个形状都是 aria-hidden,所以对读屏用户来说,整个加载态就是这一句。
填充Skeleton 本身:一个 --stone 的矩形,高一行——h-3——而它是个 div,本来就占满宽度。高是这个元素自己唯一没有的那一维,所以基础组件补上的正是这一维;className 里写什么都会把它换掉。
线条、块和圆架在那个填充之上的三个预设。SkeletonLine 是 12px 高的胶囊,宽度由调用处给;SkeletonBlock 只设了 --radius-sm;SkeletonCircle 是固定 36px 的圆。
段落SkeletonText:默认三行,最后一行 62%,这样它像散文那样收在半行,而不是齐口收成一张表。

实践建议

推荐

  • 哪怕只有一条,也要用 SkeletonPage 把形状包起来。role、aria-busy 和脉冲全长在外框上,所以散着用的零件是 aria-hidden、不出声、也完全不动的——一个永远不会有下文的灰矩形。
  • 每一个 Skeleton 都要给出它所替代的那个东西的高度。基础组件会退回一行高,所以忘了给尺寸的块并不是看不见——它是一根 12px 的条,而那个位置马上要落下一块 160px 的板,页面就按这个差值跳一下。
  • 接替骨架屏的东西要么自己播报,要么接过焦点。这里的 aria-busy 从来不会翻成 false——外框是被卸载,不是被更新——所以等待的结束,就是唯一在说话的那个东西消失了。

避免

  • 不要给零件加 animate-pulse。外框已经在动 opacity,子元素上再来一层会和它相乘,于是这个零件的节奏和它所在的形状对不上。理由不在减少动效那一头:keyframes.css 里那条规则是加在 animation-duration 和 transition-duration 上的通用下限,手写的 Tailwind 动画和别的一样会被压掉。data-folio-animated 是组件在声明自己的动效只是装饰,不是把动效取消掉的那个机制。
  • 不要把一个 SkeletonPage 套进另一个里。每一个都是带着自己那句 sr-only 的 role="status" 区域,所以由两段骨架拼起来的页面,会为同一次等待播报两条加载消息、标记两个区域忙碌。
  • 不要把一份骨架原样搬到另一个页面。无论它替的是什么,SkeletonCircle 都是 36px、SkeletonLine 都是 12px 高,所以照抄过来的头像位和标题位,在真内容落地的那一刻就是两次必然发生的重排。

示例

record list

那份列表的骨架,和取代它的东西建立在同一套栅格上——一块 9rem 的板,旁边是引题、标题和两行正文。这份吻合就是全部的活儿:一个形状和最终落下来的东西不一样的骨架,是一次读者事先被预告过、然后照样挨了一下的布局位移。一个 SkeletonPage 把整批包住,于是只有一个实时区域、一句被念出来的话,和一次脉动,而不是八条各自岔开呼吸的横条。这里没有任何东西把 aria-busy 翻成 false——这个框是被卸载的,不是被更新的——所以取代它的东西必须自己播报,或者接管焦点。

Loading projects

parts

四个预设,配同一种填充。Skeleton 自己只设一层 --stone 底色,别的什么都不设——没有高度、没有宽度、没有圆角——所以一个光秃秃的它渲染出来是一个零高度的 div,什么都看不见;每一个尺寸都来自 className。SkeletonCircle 固定 36px,SkeletonLine 固定 12px 高,无论它们代替的是什么,这也是为什么一份在不同屏幕之间复制过来的骨架,必须对着它现在所代替的东西重新量一遍。别给某个部件再加 animate-pulse:那个框已经在做透明度动画了,子元素上的第二条曲线会和它相乘。

Loading the example

shape match

同一条评论两遍:占住位置的那个形状,以及落进这个位置里的东西。SkeletonCircle 是 36px,而默认尺寸的 Avatar 也是 36px;那条名字线的高度,就是取代它的那行说明文字的高度;两条正文横条结束的地方,正是真正的句子结束的地方——所以换过来的那一刻,页面上没有任何东西动。一份没有对着任何东西量过的骨架,是一次多绕了几步的布局位移:它承诺了一种布局,然后页面从那个已经在读它的读者脚下跳开。

While it loads

Loading the comment

What lands

Henry Chen

Rebased onto main and the typecheck is green again. Merging once CI finishes.

组成部分

在调用处组合,而不是通过 props 配置——所以这个组件没有预料到的布局,依然表达得出来。

SkeletonLine

一行文字。高度是行高;宽度是它排到多远。

同时接受 HTMLAttributes<HTMLDivElement> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

SkeletonBlock

一个块:图片位、底板、图表。

同时接受 HTMLAttributes<HTMLDivElement> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

SkeletonCircle

一个圆:头像、标记点。

同时接受 HTMLAttributes<HTMLDivElement> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

SkeletonText

一段文字。

最后一行是短的,因为真实的文字会在半行处结束。一堆等宽的满行条读起来像表格,等真的来了一段文字,读者会愣一下。

SkeletonText props
属性类型默认值说明
linesnumber3替几行文字站位。

同时接受 HTMLAttributes<HTMLDivElement> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

SkeletonPage

骨架所在的框架:live region,以及那唯一一次脉动。

animate-pulse 只在外层包一次,而不是每个部件一次,这样整页一起呼吸,而不是各自错拍地闪——二十根各自动画的条是一层视觉噪声底,不是加载状态。

一个形状和替换它的东西对不上的骨架,比没有骨架更糟:它承诺了一个布局,然后页面从读者脚下跳走了。请照着真实的东西去组合这些部件,并且两边一起改。

SkeletonPage props
属性类型默认值说明
children必填ReactNode—
label必填string正在加载什么,用文字说。只播报一次;形状本身是 `aria-hidden` 的,所以对读屏用户来说这句话就是全部体验,而光说一个“加载中”等于什么都没说。

同时接受 HTMLAttributes<HTMLDivElement> 里的全部属性,它们会直接透传给底层元素,不再逐条列出。

无障碍

  • 一个 live region 在外层,里面每个形状都是 aria-hidden。
  • 脉冲也只有外层一个,所以整页一起呼吸,而不是各闪各的。