Expokit
AboutDocsComponentsChartsBlocksPricing
Sign inGet started
All components

Inputs

CalendarNewDate PickerNewFloating InputOTP InputPassword InputSearch BarSelectSliderStepperTag InputTextareaTime PickerNew

Selection

CheckboxChip GroupColor SwatchProRadio GroupRatingProSegmented ControlSwitch

Buttons

ButtonFloating Action ButtonProLike ButtonProMorph ButtonProShimmer ButtonProSlide to ConfirmProStateful Button

Feedback

AlertProgressSkeletonSpinnerToast

Overlays

Action SheetNewBottom SheetDialogDrawerNew

Display

AccordionAvatarBadgeCardCarouselProList ItemNewPagination DotsNewSwipeable RowProTimelineNew

Navigation

Bottom NavNewProgress StepsNewTabs

Motion

Number TickerProText Animation
Skeleton
Sponsor ExpoKit

Free stays free. Tip once — keep shipping.

Support
About
Docs
IntroductionWhat ExpoKit is and how it worksInstallationAdd ExpoKit to an Expo appThemingColours, radius and dark modeCLIinit, add, login and updateAI assistants (MCP)Cursor, Claude, Antigravity and moreRun on your phoneTry components in Expo GoChangelogWhat shipped recently
npx @expokit/cli@latest initQuick start
Components
Liquid Glass8 iOS 26 glass pieces3DNew14 tilt, flip and stack
Inputs 12
  • CalendarNew
  • Date PickerNew
  • Floating Input
  • OTP Input
  • Password Input
  • +7 more
Selection 7
  • Checkbox
  • Chip Group
  • Color SwatchPro
  • Radio Group
  • RatingPro
  • +2 more
Buttons 7
  • Button
  • Floating Action ButtonPro
  • Like ButtonPro
  • Morph ButtonPro
  • Shimmer ButtonPro
  • +2 more
Feedback 5
  • Alert
  • Progress
  • Skeleton
  • Spinner
  • Toast
Overlays 4
  • Action SheetNew
  • Bottom Sheet
  • Dialog
  • DrawerNew
Display 9
  • Accordion
  • Avatar
  • Badge
  • Card
  • CarouselPro
  • +4 more
Navigation 3
  • Bottom NavNew
  • Progress StepsNew
  • Tabs
Motion 2
  • Number TickerPro
  • Text Animation
70 components · 347 live variantsAll components
Charts

Cartesian

  • Area Chart
  • Bar Chart
  • Line Chart
  • Combo Chart

Circular

  • Pie & Donut
  • Radial Chart
  • Radar Chart
  • Polar AreaPro

3D & Pixel

  • Isometric BarsPro
  • Pixel BarsPro

Specialty

  • TreemapPro
  • WaterfallPro
  • BulletPro
  • TimelinePro
  • Gauge
  • Candlestick
  • Heatmap
  • Funnel

Classic

  • Sparkline
  • Progress Rings
  • MultiLine (classic)
  • StackedBar (classic)
  • Ranking Bars (classic)

Guides

  • Usage & config
  • Patterns & effects
  • Show / hide anything
  • Chart theming
  • Interactions
23 charts, built on the open-source expo-charts engineAll charts
Blocks

Screens

ChatProAn AI assistant chat screenCheckoutProA premium checkoutDashboardProA finance / analytics home screenInboxProA messaging inboxOnboardingProA minimal swipeable welcome flowProfileProAccount profile for any appSearch ResultsProA clean, monochrome search screenSettingsProAn iOS-style grouped settings screenSign InProOne configurable sign-in flow modelled on top mobile apps
9 complete screens — drop one in, wire your dataSee them on devices All blocks
Pricing
DocsComponentsFeedbackSkeleton

Skeleton

Scan with Expo Go

Skeleton with all its variants, native in Expo Go. No account needed.

No Expo Go? Open in your browser

Loading placeholders swept by one angled light band across the whole screen.

<SkeletonGroup />9 variantsiOS · Android · Expo Go
9:41

Sunday 18 October

9:41

Installation#

One command copies the source into your app — you own the code and can change anything.

Usage#

Colours, radius and light / dark come from useKit(). Wrap your app in <KitProvider accent="#6366f1" radius={14}> to re-skin every component at once.

Props#

PropTypeDefault
width / height

Block width and height, in points or percent.

DimensionValue"100%" / 14
radius

Corner radius. 'capsule' follows the kit radius for bars; 'full' is always a pill / circle.

"sm" | "md" | "lg" | "xl" | "capsule" | "full" | number"sm"
variant

Angled light band sweeping all blocks as one wave, a staggered top-to-bottom brighten, an opacity pulse, or no motion. Every variant is static when reduce motion is on.

"shimmer" | "wave" | "pulse" | "static""shimmer"
color

Block colour.

stringfaint foreground
children

Centred overlay inside the block (e.g. a faint glyph).

ReactNode—
Skeleton.Text

Paragraph placeholder with a slightly ragged edge.

{ lines, lineHeight, gap, lastLineWidth }3 lines
Skeleton.Avatar

Avatar placeholder.

{ size, shape: "circle" | "rounded" }40, circle
Skeleton.Media

Image block with a faint picture glyph in the centre.

{ aspectRatio, height, width, glyph, glyphSize, radius }16 / 9, glyph off
Skeleton.List

Rows with an avatar, two lines and a trailing pill.

{ rows, avatarSize, avatarShape, trailing, dividers }4 rows, 44, circle, trailing on
Skeleton.Card

Feed post: author row with menu, caption, media and an action bar.

{ media, mediaHeight, header, lines, actions }all on, 168
Skeleton.Nav

Screen chrome while content loads — app bar, list body, bottom tabs.

{ header, back, actions, tabs, tabCount, rows, children }header + 4 tabs + 4 rows
Skeleton.Group

Frame the shimmer / wave travels across, so blocks inside line up as one wave (Nav and Reveal add one).

{ style, children }window
Skeleton.Reveal

Shows `skeleton` while loading; then it dissolves, top-level children rise in with a stagger and the height glides between the two.

{ loading, skeleton, children, stagger, animateHeight, loadingLabel }—, 3 lines, —, 70, true, "Loading"
style

Style for the block (composites apply it to their wrapper).

StyleProp<ViewStyle>—

Behaviour & accessibility#

Motion
Every block reads one shared native-driver clock, so the shimmer band, wave or pulse moves across a Skeleton.Group (or the window) as a single sweep.
Reduced motion
Every variant renders static, and Skeleton.Reveal swaps the skeleton for content instantly.
Screen readers
Placeholder blocks are hidden from screen readers. Skeleton.Reveal is marked busy and exposes a progressbar named by loadingLabel; the content arrives in a polite live region.
Platforms
Plain Animated views plus a react-native-svg gradient, so it runs the same on iOS, Android and web.

Source#

PreviousProgressNextSpinner

Did you like the content?

Coding agents

Chat apps

Other

9:41

Shimmer

Skeleton

Manual installation
  1. Install dependencies

    terminal
    npx expo install react-native-svg
  2. Copy the files

    Create each file at the same path and paste its code. @/ points at your src/ folder (the default in new Expo apps). Skip any file you already have, like lib/kit.tsx.

    • src/components/ui/skeleton.tsx800 lines
    • src/lib/kit.tsx220 lines

    Want to read it first? Every file is in Source below.

  3. Use it

    App.tsx
    import { SkeletonGroup } from "@/components/ui/skeleton";

    See Usage for a full example.

Example.tsx
import { Skeleton } from '@/components/ui/skeleton'; <Skeleton.Group style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>  <Skeleton.Avatar size={44} />  <Skeleton.Text lines={2} style={{ flex: 1 }} /></Skeleton.Group>

Every variant above has a complete, runnable example in its Code tab.

src/components/ui/skeleton.tsx
import React, { createContext, useContext, useEffect, useId, useMemo, useRef, useState, type ComponentRef } from 'react';import {  AccessibilityInfo,  Animated,  Easing,  StyleSheet,  useWindowDimensions,  View,  type DimensionValue,  type LayoutChangeEvent,  type StyleProp,  type ViewStyle,} from 'react-native';import Svg, { Circle, Defs, LinearGradient, Path, Rect, Stop } from 'react-native-svg'; import { alpha, motion, useKit } from '@/lib/kit'; export type SkeletonVariant = 'shimmer' | 'wave' | 'pulse' | 'static'; export interface SkeletonProps {  width?: DimensionValue;  height?: DimensionValue;  /**   * Corner radius: a theme step, 'capsule' for bars that follow the kit radius (a pill at the   * default radius), 'full' for true pills / circles, or points.   */  radius?: 'sm' | 'md' | 'lg' | 'xl' | 'capsule' | 'full' | number;  /**   * 'shimmer' sweeps one angled light band across every block on screen, 'wave' brightens blocks   * one after another from top to bottom, 'pulse' breathes, 'static' does nothing.   */  variant?: SkeletonVariant;  /** Base colour of the block (default: a faint tint of the foreground). */  color?: string;  /** Centred overlay, e.g. a faint glyph (see `Skeleton.Media`). */  children?: React.ReactNode;  style?: StyleProp<ViewStyle>;} /* ------------------------------------------------------------------------------------------------ * One shared clock for every skeleton on screen. It runs 0 → 1 linearly on the native driver; * each variant maps it through its own (sampled) curve, so all blocks stay in lock-step. * --------------------------------------------------------------------------------------------- */ const CYCLE = 2100;/** Share of the cycle the shimmer band spends travelling; the rest is a calm pause. */const SWEEP = 0.7;const ANGLE = (20 * Math.PI) / 180;const TAN = Math.tan(ANGLE);const COS = Math.cos(ANGLE);const SIN = Math.sin(ANGLE); const clock = new Animated.Value(0);let users = 0;let loop: Animated.CompositeAnimation | null = null; function useClock(active: boolean) {  useEffect(() => {    if (!active) return;    users += 1;    if (users === 1) {      clock.setValue(0);      loop = Animated.loop(Animated.timing(clock, { toValue: 1, duration: CYCLE, easing: Easing.linear, useNativeDriver: true }));      loop.start();    }    return () => {      users -= 1;      if (users === 0) {        loop?.stop();        loop = null;      }    };  }, [active]);  return clock;} // Reduce motion, read once and shared by every block (one OS listener, not one per bar).let reduceMotion = false;const reduceSubs = new Set<(v: boolean) => void>();let reduceSub: { remove: () => void } | null = null; function useReduceMotion() {  const [reduce, setReduce] = useState(reduceMotion);  useEffect(() => {    reduceSubs.add(setReduce);    if (!reduceSub) {      const publish = (v: boolean) => {        reduceMotion = v;        reduceSubs.forEach((fn) => fn(v));      };      AccessibilityInfo.isReduceMotionEnabled().then(publish).catch(() => {});      reduceSub = AccessibilityInfo.addEventListener('reduceMotionChanged', publish);    }    return () => {      reduceSubs.delete(setReduce);      if (reduceSubs.size === 0) {        reduceSub?.remove();        reduceSub = null;      }    };  }, []);  return reduce;} /** Samples `f` on [from, to] so a curve can run on the native driver (which has no `easing`). */function sample(f: (x: number) => number, from: number, to: number, n: number) {  const inputRange: number[] = [];  const outputRange: number[] = [];  for (let i = 0; i <= n; i++) {    const x = from + ((to - from) * i) / n;    inputRange.push(x);    outputRange.push(f(x));  }  return { inputRange, outputRange };} const easeSweep = Easing.inOut(Easing.sin);const easeRise = Easing.out(Easing.cubic); /* ------------------------------------------------------------------------------------------------ * Group: the frame one wave travels across. Without a group it is the window, which on a phone is * the screen. Nested groups defer to the outermost one so a whole screen stays one wave. * --------------------------------------------------------------------------------------------- */ interface Frame {  x: number;  y: number;  w: number;  h: number;} const GroupContext = createContext<{ frame: Frame | null } | null>(null); function measure(node: ComponentRef<typeof View> | null, e: LayoutChangeEvent, done: (f: Frame) => void) {  const { x, y, width, height } = e.nativeEvent.layout;  const fallback = () => done({ x, y, w: width, h: height });  if (!node || typeof node.measureInWindow !== 'function') return fallback();  node.measureInWindow((wx, wy) => {    if (Number.isFinite(wx) && Number.isFinite(wy)) done({ x: wx, y: wy, w: width, h: height });    else fallback();  });} export interface SkeletonGroupProps {  children?: React.ReactNode;  style?: StyleProp<ViewStyle>;} /** * Optional frame for the shimmer / wave: every block inside lines up as one wave across this * view instead of across the whole window. Useful for a card or a preview that is narrower than * the window (tablets, web). `Skeleton.Nav` and `Skeleton.Reveal` set one up for you. */export function SkeletonGroup({ children, style }: SkeletonGroupProps) {  const parent = useContext(GroupContext);  const ref = useRef<ComponentRef<typeof View>>(null);  const [frame, setFrame] = useState<Frame | null>(null);  const value = useMemo(() => ({ frame }), [frame]);  if (parent) return <View style={style}>{children}</View>;  return (    <View ref={ref} style={style} onLayout={(e) => measure(ref.current, e, setFrame)}>      <GroupContext.Provider value={value}>{children}</GroupContext.Provider>    </View>  );} /* ------------------------------------------------------------------------------------------------ * Block * --------------------------------------------------------------------------------------------- */ /** * Placeholder block for loading content. An angled, soft-edged light band sweeps across every * block on screen as one wave (or blocks brighten one after another, or gently pulse), all from * one shared native-driver clock. Static when the OS "reduce motion" setting is on. * Compose with `Skeleton.Text`, `.Avatar`, `.Media`, `.List`, `.Card`, `.Nav` and `.Reveal`. */export function Skeleton({ width = '100%', height = 14, radius = 'sm', variant = 'shimmer', color, children, style }: SkeletonProps) {  const { colors, radius: r, dark } = useKit();  const reduce = useReduceMotion();  const mode: SkeletonVariant = reduce ? 'static' : variant;  const moving = mode !== 'static';  const t = useClock(moving);  const group = useContext(GroupContext);  const win = useWindowDimensions();  const ref = useRef<ComponentRef<typeof View>>(null);  const [box, setBox] = useState<Frame | null>(null);  const gid = 'sk' + useId().replace(/[^a-zA-Z0-9]/g, '');   const base = color ?? alpha(colors.foreground, dark ? 0.11 : 0.09);  // Light mode: a whisper of white over a pale block. Dark mode: a touch more lift so it reads.  const shine = dark ? colors.foreground : '#ffffff';  const peak = dark ? (mode === 'wave' ? 0.07 : 0.11) : mode === 'wave' ? 0.5 : 0.62;  const borderRadius =    typeof radius === 'number'      ? radius      : radius === 'capsule'        ? r.capsule(typeof height === 'number' ? height : 14)        : r[radius];   // Where this block sits inside the wave's frame.  const fx = group?.frame?.x ?? 0;  const fy = group?.frame?.y ?? 0;  const W = group?.frame?.w || win.width;  const H = Math.min(group?.frame?.h || win.height, 900);  const dx = box ? box.x - fx : 0;  const dy = box ? Math.max(0, Math.min(H, box.y - fy + box.h / 2)) : 0;  const bh = box?.h ?? 0;  const B = Math.max(160, Math.min(320, W * 0.55));  const bw = B + bh * TAN;   const shimmerX = useMemo(() => {    if (mode !== 'shimmer') return null;    // Global position of the band's centre line at the top of the frame, then this block's slice.    const start = -B;    const end = W + H * TAN + B;    const local = (x: number) => start + (end - start) * easeSweep(x / SWEEP) - dy * TAN - dx - bw / 2;    const s = sample(local, 0, SWEEP, 24);    s.inputRange.push(1);    s.outputRange.push(s.outputRange[s.outputRange.length - 1]);    return t.interpolate(s);  }, [mode, B, W, H, dx, dy, bw, t]);   const waveOpacity = useMemo(() => {    if (mode !== 'wave') return null;    const phase = (dy / Math.max(1, H)) * 0.55;    const half = 0.24;    return t.interpolate(      sample(        (x) => {          const d = Math.abs(x - phase);          const dist = Math.min(d, 1 - d);          return dist < half ? peak * (0.5 + 0.5 * Math.cos((Math.PI * dist) / half)) : 0;        },        0,        1,        36,      ),    );  }, [mode, dy, H, peak, t]);   const pulse = useMemo(    () =>      mode === 'pulse'        ? { opacity: t.interpolate(sample((x) => 1 - 0.5 * (0.5 - 0.5 * Math.cos(2 * Math.PI * x)), 0, 1, 24)) }        : null,    [mode, t],  );   const L = (B / 2) * COS;  const cx = bw / 2;  const cy = bh / 2;   return (    <Animated.View      ref={ref}      accessibilityElementsHidden      importantForAccessibility="no-hide-descendants"      onLayout={mode === 'shimmer' || mode === 'wave' ? (e) => measure(ref.current, e, setBox) : undefined}      style={[styles.block, { width, height, borderRadius, backgroundColor: base }, pulse, style]}    >      {shimmerX && box && bh > 0 ? (        <Animated.View pointerEvents="none" style={[styles.band, { width: bw, transform: [{ translateX: shimmerX }] }]}>          <Svg width={bw} height={bh}>            <Defs>              <LinearGradient                id={gid}                gradientUnits="userSpaceOnUse"                x1={cx - L * COS}                y1={cy - L * SIN}                x2={cx + L * COS}                y2={cy + L * SIN}              >                <Stop offset="0" stopColor={shine} stopOpacity={0} />                <Stop offset="0.22" stopColor={shine} stopOpacity={peak * 0.12} />                <Stop offset="0.4" stopColor={shine} stopOpacity={peak * 0.62} />                <Stop offset="0.5" stopColor={shine} stopOpacity={peak} />                <Stop offset="0.6" stopColor={shine} stopOpacity={peak * 0.62} />                <Stop offset="0.78" stopColor={shine} stopOpacity={peak * 0.12} />                <Stop offset="1" stopColor={shine} stopOpacity={0} />              </LinearGradient>            </Defs>            <Rect x="0" y="0" width={bw} height={bh} fill={`url(#${gid})`} />          </Svg>        </Animated.View>      ) : null}      {waveOpacity ? (        <Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { backgroundColor: shine, opacity: waveOpacity }]} />      ) : null}      {children != null ? (        <View pointerEvents="none" style={styles.center}>          {children}        </View>      ) : null}    </Animated.View>  );} /* ------------------------------------------------------------------------------------------------ * Composites * --------------------------------------------------------------------------------------------- */ export interface SkeletonTextProps {  /** Number of lines (default 3). */  lines?: number;  /** Line height in points (default 12). */  lineHeight?: number;  gap?: number;  /** Width of the last line, so it reads like a paragraph (default '60%'). */  lastLineWidth?: DimensionValue;  variant?: SkeletonVariant;  style?: StyleProp<ViewStyle>;} const RAGGED: DimensionValue[] = ['100%', '94%', '97%', '90%']; /** Paragraph placeholder: `lines` bars with a slightly ragged right edge and a shorter last line. */export function SkeletonText({ lines = 3, lineHeight = 12, gap = 10, lastLineWidth = '60%', variant, style }: SkeletonTextProps) {  return (    <View style={[{ gap }, style]}>      {Array.from({ length: lines }, (_, i) => (        <Skeleton          key={i}          variant={variant}          height={lineHeight}          radius="capsule"          width={i === lines - 1 && lines > 1 ? lastLineWidth : RAGGED[i % RAGGED.length]}        />      ))}    </View>  );} export interface SkeletonAvatarProps {  size?: number;  shape?: 'circle' | 'rounded';  variant?: SkeletonVariant;  style?: StyleProp<ViewStyle>;} /** Round (or rounded-square) avatar placeholder. */export function SkeletonAvatar({ size = 40, shape = 'circle', variant, style }: SkeletonAvatarProps) {  const { radius } = useKit();  return (    <Skeleton      variant={variant}      width={size}      height={size}      radius={shape === 'circle' ? 'full' : radius.scale(Math.round(size * 0.28))}      style={style}    />  );} export interface SkeletonMediaProps {  /** Width / height ratio (default 16 / 9). Ignored when `height` is set. */  aspectRatio?: number;  /** Fixed height in points. */  height?: number;  width?: DimensionValue;  /** Draw a faint image glyph in the centre (default false). */  glyph?: boolean;  /** Glyph size in points (default 28). */  glyphSize?: number;  radius?: SkeletonProps['radius'];  variant?: SkeletonVariant;  style?: StyleProp<ViewStyle>;} /** Image / video placeholder (optionally with a faint picture glyph in the middle). */export function SkeletonMedia({  aspectRatio = 16 / 9,  height,  width = '100%',  glyph = false,  glyphSize = 28,  radius = 'md',  variant,  style,}: SkeletonMediaProps) {  const { colors, dark } = useKit();  const ink = alpha(colors.foreground, dark ? 0.2 : 0.14);  return (    <Skeleton      variant={variant}      width={width}      height={height ?? 'auto'}      radius={radius}      style={[height == null ? { aspectRatio } : null, style]}    >      {glyph ? (        <Svg width={glyphSize} height={glyphSize} viewBox="0 0 24 24" fill="none">          <Path            d="M3.5 7A3.5 3.5 0 0 1 7 3.5h10A3.5 3.5 0 0 1 20.5 7v10a3.5 3.5 0 0 1-3.5 3.5H7A3.5 3.5 0 0 1 3.5 17z"            stroke={ink}            strokeWidth={1.6}          />          <Circle cx={9} cy={9.2} r={1.7} fill={ink} />          <Path d="M4 17.2l4.6-4.4a1.6 1.6 0 0 1 2.2 0l2.4 2.3 1.6-1.5a1.6 1.6 0 0 1 2.2 0l3.4 3.2" stroke={ink} strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" />        </Svg>      ) : null}    </Skeleton>  );} export interface SkeletonListProps {  /** Number of rows (default 4). */  rows?: number;  /** Avatar size (default 44). 0 hides it. */  avatarSize?: number;  avatarShape?: 'circle' | 'rounded';  /** Trailing short bar, like a time stamp or price (default true). */  trailing?: boolean;  /** Hairline between rows (default false). */  dividers?: boolean;  variant?: SkeletonVariant;  style?: StyleProp<ViewStyle>;} const TITLE_W: DimensionValue[] = ['58%', '44%', '66%', '50%', '62%'];const SUB_W: DimensionValue[] = ['86%', '74%', '80%', '68%', '90%']; /** List rows: avatar, title + subtitle lines and a trailing pill. Widths vary so it reads as real data. */export function SkeletonList({  rows = 4,  avatarSize = 44,  avatarShape = 'circle',  trailing = true,  dividers = false,  variant,  style,}: SkeletonListProps) {  const { colors } = useKit();  return (    <View style={style}>      {Array.from({ length: rows }, (_, i) => (        <View          key={i}          style={[            styles.listRow,            dividers && i > 0 ? { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.border } : null,          ]}        >          {avatarSize > 0 ? <SkeletonAvatar variant={variant} size={avatarSize} shape={avatarShape} /> : null}          <View style={styles.rowText}>            <Skeleton variant={variant} width={TITLE_W[i % TITLE_W.length]} height={13} radius="capsule" />            <Skeleton variant={variant} width={SUB_W[i % SUB_W.length]} height={11} radius="capsule" />          </View>          {trailing ? (            <View style={styles.trailing}>              <Skeleton variant={variant} width={i % 3 === 1 ? 30 : 40} height={11} radius="capsule" />            </View>          ) : null}        </View>      ))}    </View>  );} export interface SkeletonCardProps {  /** Show the media block (default true). */  media?: boolean;  mediaHeight?: number;  /** Show the author row: avatar, name, handle · time, overflow menu (default true). */  header?: boolean;  /** Body text lines (default 2). */  lines?: number;  /** Like / comment / share row along the bottom (default true). */  actions?: boolean;  variant?: SkeletonVariant;  style?: StyleProp<ViewStyle>;} /** A feed post placeholder: author row, caption, media and an action bar — borderless, like the content it stands in for. */export function SkeletonCard({  media = true,  mediaHeight = 168,  header = true,  lines = 2,  actions = true,  variant,  style,}: SkeletonCardProps) {  return (    <View style={[styles.card, style]}>      {header ? (        <View style={styles.row}>          <SkeletonAvatar variant={variant} size={40} />          <View style={styles.rowText}>            <Skeleton variant={variant} width="46%" height={13} radius="capsule" />            <Skeleton variant={variant} width="28%" height={11} radius="capsule" />          </View>        </View>      ) : null}      {lines > 0 ? <SkeletonText variant={variant} lines={lines} lineHeight={12} gap={9} lastLineWidth="72%" /> : null}      {media ? <SkeletonMedia variant={variant} height={mediaHeight} radius="lg" /> : null}      {actions ? (        <View style={styles.actions}>          {[24, 20, 0].map((label, i) => (            <View key={i} style={styles.action}>              <Skeleton variant={variant} width={22} height={22} radius="full" />              {label ? <Skeleton variant={variant} width={label} height={10} radius="capsule" /> : null}            </View>          ))}          <View style={styles.flex} />          <Skeleton variant={variant} width={22} height={22} radius="full" />        </View>      ) : null}    </View>  );} export interface SkeletonNavProps {  /** App bar: back + title + trailing actions (default true). */  header?: boolean;  /** Show a back chevron placeholder (default true when header is on). */  back?: boolean;  /** Trailing action pills in the header (default 2). */  actions?: number;  /** Bottom tab bar (default true). */  tabs?: boolean;  /** Number of bottom tabs (default 4). */  tabCount?: number;  /** Body: how many list rows to show while content loads (default 4). 0 = empty body. */  rows?: number;  /** Custom body instead of the default list rows. */  children?: React.ReactNode;  variant?: SkeletonVariant;  style?: StyleProp<ViewStyle>;} /** * Full-screen loading chrome for mobile: app header + body placeholders + bottom tabs. * Keep the nav fixed while whatever the screen fetches paints in the middle. */export function SkeletonNav({  header = true,  back = true,  actions = 2,  tabs = true,  tabCount = 4,  rows = 4,  children,  variant,  style,}: SkeletonNavProps) {  const { colors } = useKit();  return (    <SkeletonGroup style={[styles.navScreen, { backgroundColor: colors.background }, style]}>      {header ? (        <View style={styles.navHeader}>          {back ? <Skeleton variant={variant} width={32} height={32} radius="full" /> : <View style={{ width: 32 }} />}          <View style={styles.flex}>            <Skeleton variant={variant} width="45%" height={16} radius="capsule" />          </View>          <View style={styles.navActions}>            {Array.from({ length: Math.max(0, actions) }, (_, i) => (              <Skeleton key={i} variant={variant} width={32} height={32} radius="full" />            ))}          </View>        </View>      ) : null}       <View style={styles.navBody}>        {children ?? (rows > 0 ? <SkeletonList variant={variant} rows={rows} avatarShape="rounded" avatarSize={40} /> : null)}      </View>       {tabs ? (        <View style={[styles.navTabs, { borderTopColor: colors.border }]}>          {Array.from({ length: Math.max(2, tabCount) }, (_, i) => (            <View key={i} style={styles.navTab}>              <Skeleton variant={variant} width={24} height={24} radius="full" />              <Skeleton variant={variant} width={i === 0 ? 30 : 24} height={8} radius="capsule" />            </View>          ))}        </View>      ) : null}    </SkeletonGroup>  );} /* ------------------------------------------------------------------------------------------------ * Reveal: skeleton → content hand-off * --------------------------------------------------------------------------------------------- */ export interface SkeletonRevealProps {  /** While true the skeleton shows; flip to false when the data arrives. */  loading: boolean;  /** Placeholder shown while loading (default: three text lines). */  skeleton?: React.ReactNode;  /** The real content. Top-level children rise in one after another. */  children?: React.ReactNode;  /** Delay between each top-level child rising in, in ms (default 70). 0 reveals it as one block. */  stagger?: number;  /** Glide the container height from the skeleton's height to the content's (default true). */  animateHeight?: boolean;  /** Screen reader label while loading (default 'Loading'). */  loadingLabel?: string;  style?: StyleProp<ViewStyle>;} type Layer = 'skeleton' | 'content';const RISE = 380;const RISE_Y = 10;const FADE_OUT = 220;const MAX_STAGGERED = 8; /** * Swaps a skeleton for real content without a jump: the skeleton dissolves, the content rises * in child by child (cross-fade + translateY), and the container glides between the two heights. * Instant when the OS "reduce motion" setting is on. */export function SkeletonReveal({  loading,  skeleton,  children,  stagger = 70,  animateHeight = true,  loadingLabel = 'Loading',  style,}: SkeletonRevealProps) {  const reduce = useReduceMotion();  const [mode, setMode] = useState<Layer>(loading ? 'skeleton' : 'content');  const [leaving, setLeaving] = useState<Layer | null>(null);  const [fixed, setFixed] = useState(false);  const heights = useRef<Record<Layer, number>>({ skeleton: 0, content: 0 });  const pending = useRef(false);  const token = useRef(0);  const h = useRef(new Animated.Value(0)).current; // JS driver: container height  const skA = useRef(new Animated.Value(loading ? 1 : 0)).current; // native: skeleton layer  const ctA = useRef(new Animated.Value(loading ? 0 : 1)).current; // native: content layer   const items = React.Children.toArray(children);  const staggered = stagger > 0 && items.length > 1;  const steps = Math.min(items.length, MAX_STAGGERED);  const total = RISE + (staggered ? stagger * (steps - 1) : 0);  const value = (l: Layer) => (l === 'skeleton' ? skA : ctA);   const run = (next: Layer, prev: Layer) => {    const id = ++token.current;    const anims: Animated.CompositeAnimation[] = [      Animated.timing(value(prev), { toValue: 0, duration: FADE_OUT, easing: Easing.linear, useNativeDriver: true }),      Animated.timing(value(next), {        toValue: 1,        duration: next === 'content' ? total : FADE_OUT,        easing: Easing.linear,        useNativeDriver: true,      }),    ];    if (animateHeight) {      anims.push(        Animated.spring(h, { toValue: heights.current[next], ...motion.gentle, overshootClamping: true, useNativeDriver: false }),      );    }    Animated.parallel(anims).start(({ finished }) => {      if (!finished || id !== token.current) return;      setFixed(false);      setLeaving(null);    });  };   useEffect(() => {    const next: Layer = loading ? 'skeleton' : 'content';    if (next === mode) return;    const prev = mode;    if (reduce) {      token.current += 1;      value(prev).setValue(0);      value(next).setValue(1);      setFixed(false);      setLeaving(null);      setMode(next);      return;    }    if (animateHeight) {      h.stopAnimation();      if (!fixed) h.setValue(heights.current[prev]);      setFixed(true);    }    const wasMounted = leaving === next;    setLeaving(prev);    setMode(next);    if (wasMounted && heights.current[next] > 0) {      run(next, prev);    } else {      value(next).setValue(0);      pending.current = true;    }    // eslint-disable-next-line react-hooks/exhaustive-deps  }, [loading]);   const onLayout = (l: Layer) => (e: LayoutChangeEvent) => {    heights.current[l] = e.nativeEvent.layout.height;    if (l === mode && pending.current) {      pending.current = false;      run(mode, l === 'skeleton' ? 'content' : 'skeleton');    }  };   const rise = (i: number) => {    const k = Math.min(i, MAX_STAGGERED - 1);    const a = (k * stagger) / total;    const b = Math.min(1, (k * stagger + RISE) / total);    const p = sample((x) => easeRise((x - a) / (b - a)), a, b, 10);    if (a > 0) {      p.inputRange.unshift(0);      p.outputRange.unshift(0);    }    if (b < 1) {      p.inputRange.push(1);      p.outputRange.push(1);    }    const v = ctA.interpolate(p);    return { opacity: v, transform: [{ translateY: v.interpolate({ inputRange: [0, 1], outputRange: [RISE_Y, 0] }) }] };  };   const layerStyle = (l: Layer) => (l === mode ? null : styles.leaving);  const showSkeleton = mode === 'skeleton' || leaving === 'skeleton';  const showContent = mode === 'content' || leaving === 'content';   return (    <Animated.View      accessibilityState={{ busy: mode === 'skeleton' }}      style={[fixed ? { height: h, overflow: 'hidden' } : null, style]}    >      {showSkeleton ? (        <Animated.View          accessible={mode === 'skeleton'}          accessibilityRole="progressbar"          accessibilityLabel={loadingLabel}          pointerEvents="none"          onLayout={onLayout('skeleton')}          style={[layerStyle('skeleton'), { opacity: skA }]}        >          <SkeletonGroup>{skeleton ?? <SkeletonText lines={3} />}</SkeletonGroup>        </Animated.View>      ) : null}      {showContent ? (        <Animated.View          accessibilityLiveRegion="polite"          pointerEvents={mode === 'content' ? 'auto' : 'none'}          onLayout={onLayout('content')}          style={[layerStyle('content'), staggered ? null : rise(0)]}        >          {staggered            ? items.map((child, i) => (                <Animated.View key={i} style={rise(i)}>                  {child}                </Animated.View>              ))            : children}        </Animated.View>      ) : null}    </Animated.View>  );} Skeleton.Text = SkeletonText;Skeleton.Avatar = SkeletonAvatar;Skeleton.Media = SkeletonMedia;Skeleton.List = SkeletonList;Skeleton.Card = SkeletonCard;Skeleton.Nav = SkeletonNav;Skeleton.Group = SkeletonGroup;Skeleton.Reveal = SkeletonReveal; const styles = StyleSheet.create({  block: { overflow: 'hidden' },  band: { position: 'absolute', top: 0, bottom: 0, left: 0 },  center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, alignItems: 'center', justifyContent: 'center' },  flex: { flex: 1 },  card: { gap: 14 },  row: { flexDirection: 'row', alignItems: 'center', gap: 12 },  rowText: { flex: 1, gap: 8 },  actions: { flexDirection: 'row', alignItems: 'center', gap: 18 },  action: { flexDirection: 'row', alignItems: 'center', gap: 7 },  listRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 12 },  trailing: { alignItems: 'flex-end', minWidth: 40 },  leaving: { position: 'absolute', top: 0, left: 0, right: 0 },  navScreen: { flex: 1, alignSelf: 'stretch', minHeight: 320, overflow: 'hidden' },  navHeader: {    flexDirection: 'row',    alignItems: 'center',    gap: 12,    paddingHorizontal: 16,    paddingVertical: 12,  },  navActions: { flexDirection: 'row', alignItems: 'center', gap: 10 },  navBody: { flex: 1, paddingHorizontal: 16 },  navTabs: {    flexDirection: 'row',    alignItems: 'center',    justifyContent: 'space-around',    paddingHorizontal: 8,    paddingTop: 12,    paddingBottom: 16,    borderTopWidth: StyleSheet.hairlineWidth,  },  navTab: { alignItems: 'center', gap: 6, minWidth: 44 },});