Skeleton
A loading placeholder component for representing content that has not loaded yet. Use it to reduce layout shift and provide immediate visual structure while data is fetched.
Features
3 Variants — rect, circle, text 3 Sizes — sm, md, lg Dimension overrides — first-class width,height, andradiusattributesMulti-line text skeletons via linesAnimation toggle — disable shimmer with animated="false"Striped pattern via stripedfor design-mode placeholdersFully themeable through CSS custom properties Accessible — reduced-motion and forced-colors support
Source Code
View Source Code
import { define, html, prop } from '@vielzeug/craft';
import { intersectionObserver } from '@vielzeug/craft/observers';
import { computed, signal, watch } from '@vielzeug/ripple';
import type { ComponentSize } from '../../types';
import { sizableBundle } from '../../shared';
import { reducedMotionMixin } from '../../styles';
import { safeCSSLength } from '../../utils';
import componentStyles from './skeleton.css?inline';
/** Skeleton loader component properties */
export type SgSkeletonProps = {
/** Toggle shimmer animation */
animated?: boolean;
/** Height override (e.g. '1rem', '3rem') */
height?: string;
/** Number of text lines for `variant='text'` */
lines?: number;
/** Radius override (e.g. '9999px', 'var(--rounded-xl)') */
radius?: string;
/** Size preset controlling line height and circle size */
size?: ComponentSize;
/** Render diagonal stripes instead of the shimmer — useful as a design-mode placeholder */
striped?: boolean;
/** Visual variant: 'rect' (default), 'circle', or 'text' */
variant?: 'rect' | 'circle' | 'text';
/** Width override (e.g. '12rem', '70%') */
width?: string;
};
/**
* A shimmer placeholder that represents loading content.
* Control dimensions via the `--skeleton-width` and `--skeleton-height` CSS custom properties,
* or via `width` / `height` inline styles.
*
* @element sg-skeleton
*
* @attr {string} variant - Shape: 'rect' (default) | 'circle' | 'text'
* @attr {string} size - Height/circle preset: 'sm' | 'md' | 'lg'
* @attr {string} width - Width override (CSS length/percentage)
* @attr {string} height - Height override (CSS length)
* @attr {string} radius - Radius override
* @attr {boolean} animated - Disable with `animated="false"`
* @attr {number} lines - Text line count (only for `variant='text'`)
* @attr {boolean} striped - Replace shimmer with diagonal stripes
*
* @cssprop --skeleton-bg - Base shimmer color
* @cssprop --skeleton-highlight - Shimmer highlight color
* @cssprop --skeleton-radius - Border radius
* @cssprop --skeleton-size - Circle fallback size
* @cssprop --skeleton-width - Width (default: 100%)
* @cssprop --skeleton-height - Height (default: var(--size-4))
* @cssprop --skeleton-line-gap - Vertical gap between text lines
* @cssprop --skeleton-last-line-width - Width of the final text line
* @cssprop --skeleton-duration - Shimmer animation duration
* @cssprop --skeleton-stripe-size - Width of each diagonal stripe (default: 6px)
*
* @part stack - Stack container.
* @part bone - Skeleton bone element.
* @example
* ```html
* <!-- Paragraph lines -->
* <sg-skeleton variant="text" lines="3" width="100%"></sg-skeleton>
*
* <!-- Avatar -->
* <sg-skeleton variant="circle" size="md"></sg-skeleton>
*
* <!-- Card image -->
* <sg-skeleton width="100%" height="10rem"></sg-skeleton>
* ```
*/
export const SKELETON_TAG = 'sg-skeleton' as const;
define<SgSkeletonProps>(SKELETON_TAG, {
props: {
...sizableBundle,
animated: prop.bool(true),
height: prop.string(),
lines: prop.number(1),
radius: prop.string(),
striped: prop.bool(false),
variant: prop.oneOf(['rect', 'circle', 'text'] as const, 'rect'),
width: prop.string(),
},
setup(props, { bind, el, onMounted }) {
const isPaused = signal(false);
const lineCount = () => {
const value = Math.floor(Number(props.lines.value));
return Number.isFinite(value) && value > 0 ? value : 1;
};
const renderLineCount = () => (props.variant.value === 'text' ? lineCount() : 1);
const styleDeps = () =>
`${props.width.value ?? ''}|${props.height.value ?? ''}|${props.radius.value ?? ''}|${props.animated.value === false ? '0' : '1'}`;
watch(
computed(styleDeps),
() => {
const safeWidth = safeCSSLength(props.width.value);
const safeHeight = safeCSSLength(props.height.value);
const safeRadius = safeCSSLength(props.radius.value);
if (safeWidth) el.style.setProperty('--skeleton-width', safeWidth);
else el.style.removeProperty('--skeleton-width');
if (safeHeight) el.style.setProperty('--skeleton-height', safeHeight);
else el.style.removeProperty('--skeleton-height');
if (safeRadius) el.style.setProperty('--skeleton-radius', safeRadius);
else el.style.removeProperty('--skeleton-radius');
const rawAnimated = el.getAttribute('animated');
const isAnimated = rawAnimated !== 'false' && props.animated.value !== false;
el.setAttribute('data-animated', isAnimated ? 'true' : 'false');
},
{ immediate: true },
);
bind({
attr: {
'data-paused': () => (isPaused.value ? true : undefined),
},
});
onMounted(() => {
const entry = intersectionObserver(el, { threshold: 0 });
watch(entry, (e) => {
const paused =
typeof e === 'object' && e !== null && 'isIntersecting' in e
? !(e as IntersectionObserverEntry).isIntersecting
: false;
isPaused.value = paused;
});
});
return html`
<div class="stack" part="stack">
${() =>
Array.from({ length: renderLineCount() }, (_, index) => {
const isLastLine =
props.variant.value === 'text' && renderLineCount() > 1 && index === renderLineCount() - 1;
return html`<div
class="bone"
part="bone"
aria-hidden="true"
:data-last="${() => (isLastLine ? 'true' : null)}"></div>`;
})}
</div>
`;
},
styles: [reducedMotionMixin, componentStyles],
});Basic Usage
<sg-skeleton></sg-skeleton> <sg-skeleton width="12rem" height="1rem"></sg-skeleton>Variants
Rectangle (Default)
Circle
Text
Sizes
Common Patterns
Card Placeholder
List Item Placeholder
Animation Control
Use animated="false" for static placeholders.
Striped
Add striped to replace the shimmer with a diagonal stripe pattern. Useful for designers building page layouts — the high-contrast stripes make it immediately obvious where content placeholders are, without relying on animation.
The spacing between lines is adjustable via the --skeleton-stripe-size CSS custom property:
<sg-skeleton striped width="100%" height="2rem" style="--skeleton-stripe-size: 16px"></sg-skeleton>API Reference
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
variant | 'rect' | 'circle' | 'text' | 'rect' | Visual shape preset |
size | 'sm' | 'md' | 'lg' | — | Height preset for rect/text; diameter for circle |
width | string | — | Width override (e.g. 12rem, 70%) |
height | string | — | Height override |
radius | string | — | Border-radius override |
animated | boolean | true | Set animated="false" to disable shimmer |
lines | number | 1 | Number of text lines (variant="text") |
striped | boolean | false | Diagonal stripe pattern instead of shimmer |
Parts
| Part | Description |
|---|---|
stack | Outer container wrapping all bones |
bone | Individual skeleton bone element |
Events
This component does not emit custom events.
CSS Custom Properties
| Property | Description | Default |
|---|---|---|
--skeleton-bg | Base color | var(--color-contrast-200) |
--skeleton-highlight | Shimmer highlight color | var(--color-contrast-100) |
--skeleton-radius | Border radius | var(--rounded-lg) |
--skeleton-size | Circle fallback size | var(--size-10) |
--skeleton-width | Component width | 100% |
--skeleton-height | Component height | var(--size-4) |
--skeleton-line-gap | Gap between text lines | var(--size-2) |
--skeleton-last-line-width | Width of the final text line | 60% |
--skeleton-duration | Shimmer animation duration | 1.6s |
--skeleton-stripe-size | SVG tile size controlling diagonal stripe density (striped) | 8px |
--skeleton-stripe-color | Color of the diagonal lines and dashed border (striped) | var(--color-contrast-400) |
Performance
The shimmer animation is automatically paused when the element scrolls out of the viewport, using an IntersectionObserver. This prevents off-screen animations from consuming GPU resources.
Animation can also be disabled entirely with animated="false" — useful for static mockups or when the parent already shows a loading spinner.
Accessibility
The skeleton component follows WAI-ARIA best practices.
sg-skeleton
- Skeleton placeholders are decorative and non-interactive.
- Each bone is marked with
aria-hidden="true"; no content is announced. - No focusable roles or tab stops are exposed.
- Shimmer animation respects
prefers-reduced-motion: reduce.
- In
forced-colorsenvironments the bone renders withButtonFacebackground andButtonTextborder for high-contrast visibility.