Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/add-skeleton-recipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@styleframe/theme": minor
"styleframe": minor
"@styleframe/core": patch
---

Add Skeleton recipe with pulse animation and supporting utilities

- Add `useSkeletonRecipe` with size (`xs`, `sm`, `md`, `lg`, `xl`) and rounded (`true`, `false`) variants, pulse animation, and dark mode support
- Add granular animation utilities: `useAnimationNameUtility`, `useAnimationDurationUtility`, `useAnimationTimingFunctionUtility`, `useAnimationIterationCountUtility`
- Switch `useWidthUtility` and `useHeightUtility` to `createUseSpacingUtility` for `@N` multiplier support
- Add compound keyframe selector support in core engine (e.g. `"0%, 100%"`)
- Add Skeleton storybook component, grid previews, and stories
- Add Skeleton documentation page
358 changes: 358 additions & 0 deletions apps/docs/content/docs/06.components/02.composables/08.skeleton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,358 @@
---
title: Skeleton
description: A loading placeholder component that displays a pulsing gray block to indicate content is being loaded. Supports multiple sizes and a rounded option through the recipe system.
---

## Overview

The **Skeleton** is a loading placeholder element used to indicate that content is being fetched or processed. The `useSkeletonRecipe()` composable creates a fully configured [recipe](/docs/api/recipes) with size and rounded options, plus a built-in pulse animation via keyframes — no additional CSS required.

The Skeleton recipe integrates directly with the default [design tokens preset](/docs/design-tokens/presets) and generates type-safe utility classes at build time with zero runtime CSS.

## Why use the Skeleton recipe?

The Skeleton recipe helps you:

- **Ship faster with sensible defaults**: Get 5 sizes and a rounded option out of the box with a single composable call.
- **Animate without extra CSS**: The pulse keyframes animation is registered automatically when you use the recipe — no manual `@keyframes` definition needed.
- **Maintain consistency**: All skeleton placeholders share the same animation timing, colors, and border radius across your application.
- **Customize without forking**: Override base styles, default variants, or filter out options you don't need — all through the options API.
- **Stay type-safe**: Full TypeScript support means your editor catches invalid size values at compile time.
- **Integrate with your tokens**: Every value references the design tokens preset, so theme changes propagate automatically.
- **Support dark mode**: Background colors adapt automatically between light and dark color schemes.

## Usage

::steps{level="4"}

#### Register the recipe

Add the Skeleton recipe to a local Styleframe instance. The global `styleframe.config.ts` provides design tokens and utilities, while the component-level file registers the recipe itself:

:::code-tree{default-value="src/components/skeleton.styleframe.ts"}

```ts [src/components/skeleton.styleframe.ts]
import { styleframe } from 'virtual:styleframe';
import { useSkeletonRecipe } from '@styleframe/theme';

const s = styleframe();

const skeleton = useSkeletonRecipe(s);

export default s;
```

```ts [styleframe.config.ts]
import { styleframe } from 'styleframe';
import { useDesignTokensPreset, useUtilitiesPreset } from '@styleframe/theme';

const s = styleframe();

useDesignTokensPreset(s);
useUtilitiesPreset(s);

export default s;
```

:::

#### Build the component

Import the `skeleton` runtime function from the virtual module and pass variant props to compute class names:

:::tabs
::::tabs-item{icon="i-devicon-react" label="React"}

```ts [src/components/Skeleton.tsx]
import { skeleton } from "virtual:styleframe";

interface SkeletonProps {
size?: "xs" | "sm" | "md" | "lg" | "xl";
rounded?: boolean;
className?: string;
}

export function Skeleton({
size = "md",
rounded = false,
className,
}: SkeletonProps) {
return (
<div
className={`${skeleton({ size, rounded: String(rounded) })} ${className ?? ""}`}
aria-hidden="true"
/>
);
}
```

::::
::::tabs-item{icon="i-devicon-vuejs" label="Vue"}

```vue [src/components/Skeleton.vue]
<script setup lang="ts">
import { skeleton } from "virtual:styleframe";

const {
size = "md",
rounded = false,
} = defineProps<{
size?: "xs" | "sm" | "md" | "lg" | "xl";
rounded?: boolean;
}>();
</script>

<template>
<div
:class="skeleton({ size, rounded: String(rounded) })"
aria-hidden="true"
/>
</template>
```

::::
:::

#### See it in action

:::story-preview
---
story: theme-recipes-skeleton--default
panel: false
---
:::

::

## Sizes

Five size variants from `xs` to `xl` control the height of the skeleton placeholder. The width is not set by the recipe &mdash; use utility classes or CSS to control it based on the content being loaded.

::story-preview
---
story: theme-recipes-skeleton--medium
panel: true
---
::

### Size Reference

::story-preview
---
story: theme-recipes-skeleton--all-sizes
height: 400
---
::

| Size | Height Token | Use Case |
|------|-------------|----------|
| `xs` | `@0.5` | Single-line metadata, small labels |
| `sm` | `@0.75` | Body text lines, descriptions |
| `md` | `@1` | Default. Standard content lines |
| `lg` | `@1.5` | Headings, larger text blocks |
| `xl` | `@2` | Titles, prominent content areas |

::note
**Good to know:** The `size` variant only controls the height. Set the width using utility classes like `_width:[250px]` or `_width:full` to match the shape of the content being loaded.
::

## Rounded

The `rounded` variant applies a fully circular border radius (`@border-radius.full`), turning the skeleton into a pill or circle shape. This is useful for placeholder avatars and circular icons.

::story-preview
---
story: theme-recipes-skeleton--rounded
panel: true
---
::

| Value | Border Radius | Use Case |
|-------|---------------|----------|
| `false` | `@border-radius.md` | Default. Rectangular placeholders for text and content blocks |
| `true` | `@border-radius.full` | Circular or pill-shaped placeholders for avatars and icons |

::tip
**Pro tip:** Combine `rounded` with equal width and height utility classes to create a perfect circle placeholder: `<Skeleton size="xl" :rounded="true" class="_width:3 _height:3" />`.
::

## Animation

The Skeleton recipe includes a built-in `skeleton-pulse` keyframes animation that fades the element between full and half opacity on a 2-second loop. The keyframes are registered automatically when the recipe is used &mdash; no additional setup is needed.

| Property | Value | Token |
|----------|-------|-------|
| Animation name | `skeleton-pulse` | &mdash; |
| Duration | `2s` | &mdash; |
| Timing function | ease-in-out | `@easing.ease-in-out` |
| Iteration count | `infinite` | &mdash; |

The `skeleton-pulse` keyframes cycle between `opacity: 1` at 0% and 100%, and `opacity: 0.5` at 50%.

## Accessibility

- **Hide skeleton placeholders from screen readers.** Skeleton elements are purely visual and carry no meaningful content. Add `aria-hidden="true"` to each skeleton element so assistive technology ignores them.

```html
<!-- Correct: hidden from assistive technology -->
<div class="..." aria-hidden="true"></div>
```

- **Mark the loading container with `aria-busy`.** Wrap skeleton placeholders in a container with `aria-busy="true"` while loading, and remove it when real content appears. This tells screen readers the region is updating.

```html
<!-- While loading -->
<div aria-busy="true">
<div class="..." aria-hidden="true"></div>
<div class="..." aria-hidden="true"></div>
</div>

<!-- After loading -->
<div>
<h2>Actual content</h2>
<p>Real description text.</p>
</div>
```

- **Provide a screen-reader-only loading message.** Since skeleton elements are hidden, add a visually hidden text element that announces the loading state to assistive technology.

```html
<div aria-busy="true">
<span class="_sr-only">Loading content...</span>
<div class="..." aria-hidden="true"></div>
</div>
```

## Customization

### Overriding Defaults

The `useSkeletonRecipe()` composable accepts an optional second argument to override any part of the recipe configuration. Overrides are deep-merged with the defaults, so you only need to specify the properties you want to change:

```ts [src/components/skeleton.styleframe.ts]
import { styleframe } from 'virtual:styleframe';
import { useSkeletonRecipe } from '@styleframe/theme';

const s = styleframe();

const skeleton = useSkeletonRecipe(s, {
base: {
borderRadius: '@border-radius.lg',
animationDuration: '1.5s',
},
defaultVariants: {
size: 'sm',
rounded: 'false',
},
});

export default s;
```

### Filtering Variants

If you only need a subset of the available sizes, use the `filter` option to limit which values are generated. This reduces the output CSS and keeps your component API focused:

```ts [src/components/skeleton.styleframe.ts]
import { styleframe } from 'virtual:styleframe';
import { useSkeletonRecipe } from '@styleframe/theme';

const s = styleframe();

// Only generate sm and md sizes
const skeleton = useSkeletonRecipe(s, {
filter: {
size: ['sm', 'md'],
},
});

export default s;
```

::note
**Good to know:** Filtering adjusts default variants that reference filtered-out values, so your recipe stays consistent.
::

## API Reference

### `useSkeletonRecipe(s, options?)`

Creates a skeleton loading placeholder recipe with a pulse animation, size variants, and a rounded option.

**Parameters:**

| Parameter | Type | Description |
|-----------|------|-------------|
| `s` | `Styleframe` | The Styleframe instance |
| `options` | `DeepPartial<RecipeConfig>` | Optional overrides for the recipe configuration |
| `options.base` | `VariantDeclarationsBlock` | Custom base styles for the skeleton |
| `options.variants` | `Variants` | Custom variant definitions for the recipe |
| `options.defaultVariants` | `Record<keyof Variants, string>` | Default variant values for the recipe |
| `options.filter` | `Record<string, string[]>` | Limit which variant values are generated |

**Variants:**

| Variant | Options | Default |
|---------|---------|---------|
| `size` | `xs`, `sm`, `md`, `lg`, `xl` | `md` |
| `rounded` | `true`, `false` | `false` |

[Learn more about recipes &rarr;](/docs/api/recipes)

## Best Practices

- **Match the shape of real content**: Size and position skeleton elements to approximate the layout of the content they replace. This reduces layout shift when content loads.
- **Use `rounded` for avatar placeholders**: Combine `rounded` with equal width and height to create circular placeholders that match avatar shapes.
- **Set width with utility classes**: The recipe controls height through `size`, but width should be set per-instance to match the expected content width.
- **Group skeletons in a container**: Wrap related skeleton elements together and use `aria-busy="true"` on the container for accessibility.
- **Filter what you don't need**: If your component only uses a few sizes, pass a `filter` option to reduce generated CSS.
- **Override defaults at the recipe level**: Set your most common size as `defaultVariants` so component consumers write less code.
- **Avoid animating too fast**: The default 2-second pulse is designed to feel natural. Faster animations can feel aggressive and slower ones can seem broken.

## FAQ

::accordion

:::accordion-item{label="Why doesn't the Skeleton recipe include color variants?" icon="i-lucide-circle-help"}
Skeleton placeholders are neutral by design. They use `@color.gray-200` in light mode and `@color.gray-800` in dark mode to blend with any surrounding content. Adding color variants would imply semantic meaning (success, error, etc.) that doesn't apply to loading states. If you need a different background color, override the `base.background` property in the options.
:::

:::accordion-item{label="How does the pulse animation work?" icon="i-lucide-circle-help"}
The recipe registers a `skeleton-pulse` keyframes animation during setup. It cycles the element's opacity between `1` (at 0% and 100%) and `0.5` (at 50%) over 2 seconds, using an ease-in-out timing function. The animation runs infinitely. The keyframes are registered automatically when you call `useSkeletonRecipe()` &mdash; no manual `@keyframes` definition is needed.
:::

:::accordion-item{label="Can I disable or change the animation?" icon="i-lucide-circle-help"}
Override the animation properties in the `base` option. To disable the animation entirely, set `animationName` to `none`. To change the speed, override `animationDuration`:

```ts [src/components/skeleton.styleframe.ts]
const skeleton = useSkeletonRecipe(s, {
base: {
animationDuration: '1s', // Faster pulse
},
});
```
:::

:::accordion-item{label="How do I create a skeleton that matches specific content dimensions?" icon="i-lucide-circle-help"}
The `size` variant controls the height. Set the width using utility classes on the element itself. For example, to simulate a text line of a specific width:

```html
<div class="skeleton({ size: 'sm' }) _width:[200px]"></div>
```

For a circular avatar placeholder, combine `rounded` with equal width and height:

```html
<div class="skeleton({ size: 'xl', rounded: 'true' }) _width:3 _height:3"></div>
```
:::

:::accordion-item{label="Can I use the Skeleton recipe without the design tokens preset?" icon="i-lucide-circle-help"}
The Skeleton recipe references design tokens like `@color.gray-200`, `@border-radius.md`, and `@easing.ease-in-out` through string refs. These tokens need to be defined in your Styleframe instance for the recipe to generate valid CSS. The easiest way is to use `useDesignTokensPreset(s)`, but you can also define the required tokens manually.
:::

:::accordion-item{label="Why does the rounded variant use string keys instead of a boolean?" icon="i-lucide-circle-help"}
Recipe variant values are always strings in the configuration object. The `rounded` variant uses `"true"` and `"false"` as string keys internally, but your component can accept a boolean prop and convert it with `String(rounded)` when passing it to the recipe function.
:::

::
23 changes: 23 additions & 0 deletions apps/storybook/src/components/components/skeleton/Skeleton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from "vue";
import { skeleton } from "virtual:styleframe";

const props = withDefaults(
defineProps<{
size?: "xs" | "sm" | "md" | "lg" | "xl";
rounded?: boolean;
}>(),
{},
);

const classes = computed(() =>
skeleton({
size: props.size,
rounded: props.rounded ? "true" : "false",
}),
);
</script>

<template>
<div :class="classes" aria-hidden="true" />
</template>
Loading
Loading