Skeleton loader
In reviewLive · web
Loading content…
Primitive shapes: block, avatar, thumb, button, chip
Composed skeletons: same footprint as the real component
State: skeleton · motion shimmer. Toggle it; the footprint holds, so the layout never jumps.
// Web: React, consuming @dizyx/nockerl-tokens. (Web is the laggard platform. Spec + live demo,// not yet exported from @dizyx/nockerl-react; promotion is tracked.)
// Mimic the FOOTPRINT of what is loading, so nothing shifts when data lands.// A skeleton is NOT a spinner and NOT a progress bar; it has the content's shape.function SessionList({ loading, sessions }: Props) { if (loading) { return ( <div aria-busy aria-live="polite"> {Array.from({ length: 4 }).map((_, i) => ( <SessionRowSkeleton key={i} /> // avatar + two lines + trailing value ))} </div> ); } return <>{sessions.map((s) => <SessionRow key={s.id} {...s} />)}</>;}
// Primitive shapes compose any skeleton. `motion="pulse"` swaps the sweep for a// calm opacity pulse; both freeze under prefers-reduced-motion.<Skeleton shape="avatar" size={28} /><Skeleton shape="thumb" radius="card" /><SkeletonText lines={3} /> {/* the last line renders shorter */}<Skeleton shape="button" motion="pulse" />// Android (Jetpack Compose). NO skeleton ships today: loading regions use a// centered CircularProgressIndicator (files/ui/FileViewer.kt, chat/ui/MessageList.kt).// This is the INTENDED core/ui skeleton, designed from the laws. The shimmer is a// Modifier that draws a travelling token highlight over a recessed placeholder.import com.nockerl.app.core.ui.skeleton
@Composablefun SessionRowSkeleton(modifier: Modifier = Modifier) { val colors = LocalNockerlColors.current Row( modifier = modifier.fillMaxWidth().heightIn(min = 56.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Box(Modifier.size(28.dp).clip(CircleShape).skeleton()) // avatar Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { Box(Modifier.fillMaxWidth(0.7f).height(12.dp).clip(NockerlProgressTrackShape).skeleton()) Box(Modifier.fillMaxWidth(0.44f).height(12.dp).clip(NockerlProgressTrackShape).skeleton()) // shorter } Box(Modifier.width(32.dp).height(12.dp).clip(NockerlProgressTrackShape).skeleton()) // trailing }}
// Modifier.skeleton() = recessed surface + the travelling shimmer; honors the// motion accessibility setting (freezes to a static placeholder).fun Modifier.skeleton(motion: SkeletonMotion = SkeletonMotion.Shimmer): Modifier// macOS: SwiftUI. Voice ships NO custom skeleton (loading = a small ProgressView// "Transcribing…", UI/RecordingHUD.swift). The NATIVE primitive is the system// .redacted(reason: .placeholder), which greeks real views into placeholder bars// at the EXACT footprint, the SwiftUI-honest skeleton. Drive it with the load state.struct SessionRow: View { let session: Session var body: some View { HStack(spacing: 12) { Circle().fill(NockerlTheme.cardSurface3).frame(width: 28, height: 28) VStack(alignment: .leading, spacing: 2) { Text(session.title).font(.system(size: 14, weight: .medium)) Text(session.subtitle).font(.system(size: 12)).foregroundStyle(NockerlTheme.onSurfaceMuted) } Spacer() Text(session.age).font(.system(size: 12)).foregroundStyle(NockerlTheme.onSurfaceMuted) } }}
// Same row, greeked while loading: the layout is identical, so nothing jumps.SessionRow(session: placeholder) .redacted(reason: .placeholder) .accessibilityLabel("Loading session")Parameters
Section titled “Parameters”| Prop | Type | Default | Description |
|---|---|---|---|
shape | 'line' | 'avatar' | 'thumb' | 'button' | 'chip' | 'line' | The placeholder silhouette. Its radius matches the real thing it replaces: avatar/chip pill, thumb card radius, button control radius, line the track radius. |
motion | 'shimmer' | 'pulse' | 'shimmer' | Animation style. shimmer sweeps a token highlight across the block (transform only); pulse animates opacity. Both freeze under prefers-reduced-motion. |
width | number | string | Block width (px or any CSS length). Lines accept percentages; the last line of a SkeletonText is rendered shorter automatically. | |
size | number | Convenience for square shapes (avatar): sets both width and height. | |
radius | 'track' | 'control' | 'card' | 'pill' | Override the shape's default radius token when composing a custom footprint. | |
lines | number | 1 | On SkeletonText: how many stacked lines to render, with a consistent gap and a shorter final line. |
The skeletons are presentational (aria-hidden). Wrap the loading region in one element with aria-busy + aria-live=“polite” so assistive tech hears a single “Loading” status, not every empty box.
| Parameter | Type | Default | Description |
|---|---|---|---|
Modifier.skeleton | Modifier | The placeholder treatment: fills the modified box with a recessed surface (cardSurface1 mixed toward the canvas) and draws the travelling shimmer over it. Apply after .clip(shape) so the silhouette matches. | |
motion | SkeletonMotion | Shimmer | Shimmer (travelling highlight, animated via rememberInfiniteTransition on a translation) or Pulse (animated alpha). Resolves to a static placeholder when Settings.Global.ANIMATOR_DURATION_SCALE is 0. |
shape | Shape | NockerlProgressTrackShape | Clip applied to the box before .skeleton(), e.g. CircleShape for an avatar, NockerlCardShape for a thumb, the 2.dp track shape for text lines. |
modifier | Modifier | Modifier | Standard sizing/layout modifier on the composed row skeleton (e.g. heightIn(min = 56.dp) to clear the 48dp target). |
SwiftUI uses the system redaction API; there is no custom Nockerl type. Apply
.redacted(reason: .placeholder) to the real view while data is loading; SwiftUI
greeks it into placeholder bars at the same footprint.
| Style | Type | Default | Description |
|---|---|---|---|
.redacted(reason:) | ViewModifier | Renders the view as placeholder content. Pass .placeholder for the skeleton treatment; the view keeps its exact size so the layout doesn't shift. | |
.unredacted() | ViewModifier | Opts a child view out of an ancestor's redaction (e.g. keep a leading icon visible while text is greeked). | |
redactionReasons | EnvironmentValues | Read in @Environment(\.redactionReasons) to branch behaviour while redacted, e.g. skip a network image or supply placeholder text. | |
.accessibilityLabel | ViewModifier | Give the redacted region a single label ("Loading…") so VoiceOver announces the loading state rather than greeked content. |