Preview
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add skeleton
Wire the tokens into your Tailwind v4 stylesheet:
/* app/globals.css */ @import 'tailwindcss'; @import '@oshon-ai/tokens/css'; @import '@oshon-ai/tokens/tailwind';
New here? Walk through the full setup — prereqs, theming, your first render.
Usage
Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.
'use client';
import { Skeleton } from '@oshon-ai/components';
export default function Example() {
return <Skeleton />;
}Default
<div role="group" aria-label="Skeleton variants" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 320 }}>
<Skeleton size="m" width={200} height={20} />
<SkeletonText size="m" lines={3} />
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<SkeletonAvatar size="m" />
<SkeletonButton size="m" />
</div>
<SkeletonField size="m" withLabel />
<SkeletonCard size="m" withAvatar bodyLines={2} />
</div>Size matrix
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
<div key="xs" data-story-size="xs">
<div role="group" aria-label="Skeleton variants" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 320 }}>
<Skeleton size="xs" width={200} height={20} />
<SkeletonText size="xs" lines={3} />
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<SkeletonAvatar size="xs" />
<SkeletonButton size="xs" />
</div>
<SkeletonField size="xs" withLabel />
<SkeletonCard size="xs" withAvatar bodyLines={2} />
</div>
</div>
<div key="s" data-story-size="s">
<div role="group" aria-label="Skeleton variants" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 320 }}>
<Skeleton size="s" width={200} height={20} />
<SkeletonText size="s" lines={3} />
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<SkeletonAvatar size="s" />
<SkeletonButton size="s" />
</div>
<SkeletonField size="s" withLabel />
<SkeletonCard size="s" withAvatar bodyLines={2} />
</div>
</div>
<div key="m" data-story-size="m">
<div role="group" aria-label="Skeleton variants" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 320 }}>
<Skeleton size="m" width={200} height={20} />
<SkeletonText size="m" lines={3} />
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<SkeletonAvatar size="m" />
<SkeletonButton size="m" />
</div>
<SkeletonField size="m" withLabel />
<SkeletonCard size="m" withAvatar bodyLines={2} />
</div>
</div>
<div key="l" data-story-size="l">
<div role="group" aria-label="Skeleton variants" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 320 }}>
<Skeleton size="l" width={200} height={20} />
<SkeletonText size="l" lines={3} />
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<SkeletonAvatar size="l" />
<SkeletonButton size="l" />
</div>
<SkeletonField size="l" withLabel />
<SkeletonCard size="l" withAvatar bodyLines={2} />
</div>
</div>
<div key="mobile" data-story-size="mobile">
<div role="group" aria-label="Skeleton variants" style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: 320 }}>
<Skeleton size="mobile" width={200} height={20} />
<SkeletonText size="mobile" lines={3} />
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<SkeletonAvatar size="mobile" />
<SkeletonButton size="mobile" />
</div>
<SkeletonField size="mobile" withLabel />
<SkeletonCard size="mobile" withAvatar bodyLines={2} />
</div>
</div>
</div>Styling
Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.
Passing Tailwind classes
Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.
<Skeleton className="ring-2 ring-offset-2 ring-blue-500" />
Data attributes
Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
--oshon-color-primary-700: var(--my-brand-color);
}Interactive states
Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.
:hover/hover:*— pointer hover:focus-visible/focus-visible:*— keyboard focus:active/active:*— pressed:disabled/disabled:*— set via thedisabledprop
Anatomy
The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.
rootThe skeleton bar (or container for compound variants). Carries `data-oshon-component="skeleton"` (or `skeleton-text|avatar|button|field|card` for compounds), `data-oshon-size`, `data-oshon-animation`, and `data-oshon-radius` for QA hooks. Always `aria-hidden="true"` on primitive variants.
gateWrapper-gate container shown when `<Skeleton loading>{children}</Skeleton>` is used. Sets `aria-busy="true"` and `aria-live="polite"` while loading; removes both when loading is false. Carries `data-oshon-component="skeleton-gate"` and `data-oshon-loading`.
childrenSlot inside the gate that hosts the real content. While `loading=true`, this slot is `aria-hidden`, `inert`, and `opacity:0` — preserving its dimensions so the layout does not shift when the gate flips off.
Keyboard
Skeleton chrome carries `aria-hidden="true"` so AT skips the decorative bars entirely. The wrapper-gate (`<Skeleton loading>{children}</Skeleton>`) sets `aria-busy="true"` on the container while loading — required by WAI-ARIA 1.2 when a widget is missing required owned elements during loading. Pairs with `aria-live="polite"` so screen readers announce when the real content arrives. Wrapped children are made `inert` so focus / keyboard / pointer cannot reach them while hidden.
Accessibility
Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.
- WCAG level
- 2.2 AA
- Screen readers tested
- VoiceOver (macOS), NVDA (Windows)
- Last axe audit
- 2026-04-28
Do / Don't
✓ Do
<Skeleton width={200} height={20} /><Skeleton circle size="m" />
<SkeletonText lines={3} size="m" lastLineWidth="60%" /><SkeletonField size="m" withLabel />
✗ Don't
const [data, setData] = useState(null);
useEffect(() => { fetch().then(setData); }, []);
return <Skeleton loading={!data}><Card data={data} /></Skeleton>;NN/g recommends no skeleton for loads under 1 second — the brief flash is more annoying than the wait. Either render nothing for fast operations, or gate the skeleton behind a 200–300ms delay so it only appears when the fetch is genuinely slow.
<Skeleton loading={uploading}><FileUploadResults /></Skeleton>Per NN/g, loads over 10 seconds need an explicit progress indicator with duration estimate — a skeleton without progress feedback feels stuck. Switch to `<ProgressIndicator />` for long operations.
<Skeleton style={{ backgroundColor: "#eee" }} width={200} height={20} />Bypassing `--oshon-color-neutral-100` defeats white-label theming (principle #6). The token already resolves to neutral-100 in the active theme — let the cascade win.
<div role="progressbar" aria-busy="true">{[...].map(i => <Skeleton ... />)}</div>Skeleton bars are decorative — they convey shape, not state. State is conveyed by the wrapper-gate via `aria-busy`. A `role="progressbar"` claim implies determinate progress which the skeleton cannot provide. Use the wrapper-gate API instead.
Design rationale
Skeleton ships three layers because the 10-library survey produced no consensus on which is "right" — shadcn ships only the primitive, Polaris/Carbon ship only pre-shapes, Radix Themes/Mantine ship only the gate. We ship all three so the developer picks the one that fits the call site instead of fighting the API. The pulse default (vs shimmer/wave) follows 7-of-10 library precedent and NN/g's active caution against motion. `aria-busy="true"` on the gate is a WAI-ARIA 1.2 normative MUST that no surveyed library implements — Oshon does. Pre-shape heights mirror `Input` and `ButtonHug` at every size (20/22/24/28/32 px) so flipping the gate causes zero CLS, which web.dev cites as the core promise of skeletons. RSC-safe: no `useState`, no event handlers, no effects — only `useId` in the gate (RSC-compatible). Animation is a pure CSS keyframe so the bundle stays under the 5kB budget while still delivering a working pulse on the server-rendered first paint. `prefers-reduced-motion: reduce` disables animation per WCAG 2.3.3 / NN/g's accessibility caveat.