diff --git a/.changeset/add-skeleton-recipe.md b/.changeset/add-skeleton-recipe.md
new file mode 100644
index 00000000..98713eee
--- /dev/null
+++ b/.changeset/add-skeleton-recipe.md
@@ -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
diff --git a/apps/docs/content/docs/06.components/02.composables/08.skeleton.md b/apps/docs/content/docs/06.components/02.composables/08.skeleton.md
new file mode 100644
index 00000000..7eb87aa6
--- /dev/null
+++ b/apps/docs/content/docs/06.components/02.composables/08.skeleton.md
@@ -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 (
+
+ );
+}
+```
+
+::::
+::::tabs-item{icon="i-devicon-vuejs" label="Vue"}
+
+```vue [src/components/Skeleton.vue]
+
+
+
+
+
+```
+
+::::
+:::
+
+#### 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 — 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: ``.
+::
+
+## 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 — no additional setup is needed.
+
+| Property | Value | Token |
+|----------|-------|-------|
+| Animation name | `skeleton-pulse` | — |
+| Duration | `2s` | — |
+| Timing function | ease-in-out | `@easing.ease-in-out` |
+| Iteration count | `infinite` | — |
+
+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
+
+
+```
+
+- **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
+
+
+
+
+
+
Actual content
+
Real description text.
+
+```
+
+- **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
+
+```
+
+## 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` | 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` | Default variant values for the recipe |
+| `options.filter` | `Record` | 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 →](/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()` — 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
+
+```
+
+For a circular avatar placeholder, combine `rounded` with equal width and height:
+
+```html
+
+```
+:::
+
+:::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.
+:::
+
+::
diff --git a/apps/storybook/src/components/components/skeleton/Skeleton.vue b/apps/storybook/src/components/components/skeleton/Skeleton.vue
new file mode 100644
index 00000000..6497f8ca
--- /dev/null
+++ b/apps/storybook/src/components/components/skeleton/Skeleton.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/apps/storybook/src/components/components/skeleton/preview/SkeletonGrid.vue b/apps/storybook/src/components/components/skeleton/preview/SkeletonGrid.vue
new file mode 100644
index 00000000..159a89cd
--- /dev/null
+++ b/apps/storybook/src/components/components/skeleton/preview/SkeletonGrid.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
Default (rectangular)
+
+
+
+
+
+
Rounded (pill/circle)
+
+
+
+
+
+
diff --git a/apps/storybook/src/components/components/skeleton/preview/SkeletonSizeGrid.vue b/apps/storybook/src/components/components/skeleton/preview/SkeletonSizeGrid.vue
new file mode 100644
index 00000000..74421eed
--- /dev/null
+++ b/apps/storybook/src/components/components/skeleton/preview/SkeletonSizeGrid.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/apps/storybook/stories/components/skeleton.stories.ts b/apps/storybook/stories/components/skeleton.stories.ts
new file mode 100644
index 00000000..9a84dded
--- /dev/null
+++ b/apps/storybook/stories/components/skeleton.stories.ts
@@ -0,0 +1,98 @@
+import type { Meta, StoryObj } from "@storybook/vue3-vite";
+
+import Skeleton from "../../src/components/components/skeleton/Skeleton.vue";
+import SkeletonGrid from "../../src/components/components/skeleton/preview/SkeletonGrid.vue";
+import SkeletonSizeGrid from "../../src/components/components/skeleton/preview/SkeletonSizeGrid.vue";
+
+const sizes = ["xs", "sm", "md", "lg", "xl"] as const;
+
+const meta = {
+ title: "Theme/Recipes/Skeleton",
+ component: Skeleton,
+ tags: ["autodocs"],
+ parameters: {
+ layout: "padded",
+ },
+ argTypes: {
+ size: {
+ control: "select",
+ options: sizes,
+ description: "The height size of the skeleton placeholder",
+ },
+ rounded: {
+ control: "boolean",
+ description: "Whether to use full border-radius (pill/circle shape)",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ({
+ components: { Skeleton },
+ template: `
+
+ `,
+ }),
+};
+
+export const AllVariants: StoryObj = {
+ render: () => ({
+ components: { SkeletonGrid },
+ template: "",
+ }),
+};
+
+export const AllSizes: StoryObj = {
+ render: () => ({
+ components: { SkeletonSizeGrid },
+ template: "",
+ }),
+};
+
+// Size stories
+export const ExtraSmall: Story = {
+ args: {
+ size: "xs",
+ },
+};
+
+export const Small: Story = {
+ args: {
+ size: "sm",
+ },
+};
+
+export const Medium: Story = {
+ args: {
+ size: "md",
+ },
+};
+
+export const Large: Story = {
+ args: {
+ size: "lg",
+ },
+};
+
+export const ExtraLarge: Story = {
+ args: {
+ size: "xl",
+ },
+};
+
+// Rounded story
+export const Rounded: Story = {
+ args: {
+ size: "lg",
+ rounded: true,
+ },
+};
diff --git a/apps/storybook/stories/components/skeleton.styleframe.ts b/apps/storybook/stories/components/skeleton.styleframe.ts
new file mode 100644
index 00000000..e71d0a55
--- /dev/null
+++ b/apps/storybook/stories/components/skeleton.styleframe.ts
@@ -0,0 +1,37 @@
+import { useSkeletonRecipe } from "@styleframe/theme";
+import { styleframe } from "virtual:styleframe";
+
+const s = styleframe();
+const { selector } = s;
+
+export const skeleton = useSkeletonRecipe(s);
+
+selector(".skeleton-grid", {
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "@spacing.md",
+ padding: "@spacing.md",
+ alignItems: "center",
+});
+
+selector(".skeleton-section", {
+ display: "flex",
+ flexDirection: "column",
+ gap: "@spacing.lg",
+ padding: "@spacing.md",
+});
+
+selector(".skeleton-row", {
+ display: "flex",
+ flexWrap: "wrap",
+ gap: "@spacing.sm",
+ alignItems: "center",
+});
+
+selector(".skeleton-label", {
+ fontSize: "@font-size.sm",
+ fontWeight: "@font-weight.semibold",
+ minWidth: "80px",
+});
+
+export default s;
diff --git a/engine/core/src/tokens/declarations.test.ts b/engine/core/src/tokens/declarations.test.ts
index 455778b0..57938e2b 100644
--- a/engine/core/src/tokens/declarations.test.ts
+++ b/engine/core/src/tokens/declarations.test.ts
@@ -152,6 +152,41 @@ describe("parseDeclarationsBlock", () => {
expect(declarations).not.toHaveProperty("100%");
});
+ it("should parse compound keyframe selectors", () => {
+ const declarations = {
+ "0%, 100%": { opacity: "1" },
+ "50%": { opacity: "0.5" },
+ };
+
+ parseDeclarationsBlock(declarations, mockContext, root, root);
+
+ expect(mockContext.selector).toHaveBeenCalledTimes(2);
+ expect(mockContext.selector).toHaveBeenCalledWith("0%, 100%", {
+ opacity: "1",
+ });
+ expect(mockContext.selector).toHaveBeenCalledWith("50%", {
+ opacity: "0.5",
+ });
+
+ expect(declarations).not.toHaveProperty("0%, 100%");
+ expect(declarations).not.toHaveProperty("50%");
+ });
+
+ it("should parse compound from/to keyframe selectors", () => {
+ const declarations = {
+ "from, to": { opacity: "1" },
+ };
+
+ parseDeclarationsBlock(declarations, mockContext, root, root);
+
+ expect(mockContext.selector).toHaveBeenCalledTimes(1);
+ expect(mockContext.selector).toHaveBeenCalledWith("from, to", {
+ opacity: "1",
+ });
+
+ expect(declarations).not.toHaveProperty("from, to");
+ });
+
it("should parse from and to keyframe selectors", () => {
const declarations = {
from: { opacity: "0" },
diff --git a/engine/core/src/tokens/declarations.ts b/engine/core/src/tokens/declarations.ts
index 9ab57168..df72028b 100644
--- a/engine/core/src/tokens/declarations.ts
+++ b/engine/core/src/tokens/declarations.ts
@@ -62,9 +62,7 @@ export function parseDeclarationsBlock(
}
} else if (
/^[.&:]/.test(key) ||
- /^\d+%$/.test(key) ||
- key === "from" ||
- key === "to"
+ /^(\d+%|from|to)(\s*,\s*(\d+%|from|to))*$/.test(key)
) {
// If the key starts with a special character or is a keyframe selector, treat it as a nested selector
const nested = declarations[key] as DeclarationsBlock;
diff --git a/theme/src/presets/useUtilitiesPreset.ts b/theme/src/presets/useUtilitiesPreset.ts
index 8b931ef3..63e948d5 100644
--- a/theme/src/presets/useUtilitiesPreset.ts
+++ b/theme/src/presets/useUtilitiesPreset.ts
@@ -411,6 +411,10 @@ import {
// Transitions and Animation
import {
useAnimationUtility,
+ useAnimationNameUtility,
+ useAnimationDurationUtility,
+ useAnimationTimingFunctionUtility,
+ useAnimationIterationCountUtility,
useTransitionBehaviorUtility,
useTransitionDelayUtility,
useTransitionDurationUtility,
@@ -587,6 +591,10 @@ export interface UtilitiesPresetConfig {
// Transitions & Animation
animation?: Record | false;
+ animationName?: Record | false;
+ animationDuration?: Record | false;
+ animationTimingFunction?: Record | false;
+ animationIterationCount?: Record | false;
transitionBehavior?: Record | false;
transitionProperty?: Record | false;
@@ -819,6 +827,16 @@ export function useUtilitiesPreset(
transformStyleValues,
);
const animation = resolveValues(config.animation, animationValues);
+ const animationName = resolveValues(config.animationName, {});
+ const animationDuration = resolveValues(config.animationDuration, {});
+ const animationTimingFunction = resolveValues(
+ config.animationTimingFunction,
+ {},
+ );
+ const animationIterationCount = resolveValues(
+ config.animationIterationCount,
+ {},
+ );
const transitionBehavior = resolveValues(
config.transitionBehavior,
transitionBehaviorValues,
@@ -1476,6 +1494,42 @@ export function useUtilitiesPreset(
);
if (animation) createAnimationUtility(animation);
+ const createAnimationNameUtility = useAnimationNameUtility(
+ s,
+ undefined,
+ undefined,
+ resolveUtilityOptions("animation-name"),
+ );
+ if (animationName) createAnimationNameUtility(animationName);
+
+ const createAnimationDurationUtility = useAnimationDurationUtility(
+ s,
+ undefined,
+ undefined,
+ resolveUtilityOptions("animation-duration"),
+ );
+ if (animationDuration) createAnimationDurationUtility(animationDuration);
+
+ const createAnimationTimingFunctionUtility =
+ useAnimationTimingFunctionUtility(
+ s,
+ undefined,
+ undefined,
+ resolveUtilityOptions("animation-timing-function"),
+ );
+ if (animationTimingFunction)
+ createAnimationTimingFunctionUtility(animationTimingFunction);
+
+ const createAnimationIterationCountUtility =
+ useAnimationIterationCountUtility(
+ s,
+ undefined,
+ undefined,
+ resolveUtilityOptions("animation-iteration-count"),
+ );
+ if (animationIterationCount)
+ createAnimationIterationCountUtility(animationIterationCount);
+
const createTransitionBehaviorUtility = useTransitionBehaviorUtility(
s,
undefined,
@@ -2793,6 +2847,10 @@ export function useUtilitiesPreset(
// Transitions and Animation
createAnimationUtility,
+ createAnimationNameUtility,
+ createAnimationDurationUtility,
+ createAnimationTimingFunctionUtility,
+ createAnimationIterationCountUtility,
createTransitionBehaviorUtility,
createTransitionUtility: useTransitionUtility(
s,
diff --git a/theme/src/recipes/index.ts b/theme/src/recipes/index.ts
index 458672bf..1ddc53ba 100644
--- a/theme/src/recipes/index.ts
+++ b/theme/src/recipes/index.ts
@@ -5,4 +5,5 @@ export * from "./callout";
export * from "./card";
export * from "./modal";
export * from "./nav";
+export * from "./skeleton";
export * from "./tooltip";
diff --git a/theme/src/recipes/skeleton/index.ts b/theme/src/recipes/skeleton/index.ts
new file mode 100644
index 00000000..8db9da5d
--- /dev/null
+++ b/theme/src/recipes/skeleton/index.ts
@@ -0,0 +1 @@
+export * from "./useSkeletonRecipe";
diff --git a/theme/src/recipes/skeleton/useSkeletonRecipe.test.ts b/theme/src/recipes/skeleton/useSkeletonRecipe.test.ts
new file mode 100644
index 00000000..b37b4436
--- /dev/null
+++ b/theme/src/recipes/skeleton/useSkeletonRecipe.test.ts
@@ -0,0 +1,144 @@
+import { styleframe } from "@styleframe/core";
+import { useDarkModifier } from "../../modifiers/useMediaPreferenceModifiers";
+import { useSkeletonRecipe } from "./useSkeletonRecipe";
+
+function createInstance() {
+ const s = styleframe();
+ for (const name of [
+ "display",
+ "background",
+ "borderRadius",
+ "height",
+ "opacity",
+ "animationName",
+ "animationDuration",
+ "animationTimingFunction",
+ "animationIterationCount",
+ ]) {
+ s.utility(name, ({ value }) => ({ [name]: value }));
+ }
+ useDarkModifier(s);
+ return s;
+}
+
+describe("useSkeletonRecipe", () => {
+ it("should create a recipe with correct metadata", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(recipe.type).toBe("recipe");
+ expect(recipe.name).toBe("skeleton");
+ });
+
+ it("should have correct base styles", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(recipe.base).toEqual({
+ display: "block",
+ background: "@color.gray-200",
+ borderRadius: "@border-radius.md",
+ animationName: "skeleton-pulse",
+ animationDuration: "2s",
+ animationTimingFunction: "@easing.ease-in-out",
+ animationIterationCount: "infinite",
+ "&:dark": {
+ background: "@color.gray-800",
+ },
+ });
+ });
+
+ describe("variants", () => {
+ it("should have all size variants", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(Object.keys(recipe.variants!.size)).toEqual([
+ "xs",
+ "sm",
+ "md",
+ "lg",
+ "xl",
+ ]);
+ });
+
+ it("should have correct size variant styles", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(recipe.variants!.size).toEqual({
+ xs: { height: "@0.5" },
+ sm: { height: "@0.75" },
+ md: { height: "@1" },
+ lg: { height: "@1.5" },
+ xl: { height: "@2" },
+ });
+ });
+
+ it("should have rounded variant with true and false keys", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(Object.keys(recipe.variants!.rounded)).toEqual(["true", "false"]);
+ });
+
+ it("should have correct rounded variant styles", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(recipe.variants!.rounded).toEqual({
+ true: { borderRadius: "@border-radius.full" },
+ false: {},
+ });
+ });
+ });
+
+ it("should have correct default variants", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(recipe.defaultVariants).toEqual({
+ size: "md",
+ rounded: "false",
+ });
+ });
+
+ it("should have no compound variants", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s);
+
+ expect(recipe.compoundVariants).toEqual(undefined);
+ });
+
+ it("should support config overrides", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s, {
+ base: { display: "inline-block" },
+ });
+
+ expect(recipe.base!.display).toBe("inline-block");
+ expect(recipe.base!.background).toBe("@color.gray-200");
+ });
+
+ it("should support filter for size variants", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s, {
+ filter: {
+ size: ["sm", "md"],
+ },
+ });
+
+ expect(Object.keys(recipe.variants!.size)).toEqual(["sm", "md"]);
+ });
+
+ it("should clear default variant when filtered out", () => {
+ const s = createInstance();
+ const recipe = useSkeletonRecipe(s, {
+ filter: {
+ size: ["xs", "sm"],
+ },
+ });
+
+ expect(recipe.defaultVariants!.size).toBeUndefined();
+ });
+});
diff --git a/theme/src/recipes/skeleton/useSkeletonRecipe.ts b/theme/src/recipes/skeleton/useSkeletonRecipe.ts
new file mode 100644
index 00000000..deff4857
--- /dev/null
+++ b/theme/src/recipes/skeleton/useSkeletonRecipe.ts
@@ -0,0 +1,46 @@
+import { createUseRecipe } from "../../utils/createUseRecipe";
+
+/**
+ * Skeleton loading placeholder recipe.
+ * Displays a pulsing gray block used during loading states.
+ */
+export const useSkeletonRecipe = createUseRecipe(
+ "skeleton",
+ {
+ base: {
+ display: "block",
+ background: "@color.gray-200",
+ borderRadius: "@border-radius.md",
+ animationName: "skeleton-pulse",
+ animationDuration: "2s",
+ animationTimingFunction: "@easing.ease-in-out",
+ animationIterationCount: "infinite",
+ "&:dark": {
+ background: "@color.gray-800",
+ },
+ },
+ variants: {
+ size: {
+ xs: { height: "@0.5" },
+ sm: { height: "@0.75" },
+ md: { height: "@1" },
+ lg: { height: "@1.5" },
+ xl: { height: "@2" },
+ },
+ rounded: {
+ true: { borderRadius: "@border-radius.full" },
+ false: {},
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ rounded: "false",
+ },
+ },
+ (s) => {
+ s.keyframes("skeleton-pulse", {
+ "0%, 100%": { opacity: "1" },
+ "50%": { opacity: "0.5" },
+ });
+ },
+);
diff --git a/theme/src/utilities/sizing/useHeightUtility.test.ts b/theme/src/utilities/sizing/useHeightUtility.test.ts
index 894d77f6..41656d23 100644
--- a/theme/src/utilities/sizing/useHeightUtility.test.ts
+++ b/theme/src/utilities/sizing/useHeightUtility.test.ts
@@ -104,6 +104,18 @@ describe("useHeightUtility", () => {
expect(typeof creator).toBe("function");
});
+
+ it("should support spacing multiplier values", () => {
+ const s = styleframe();
+ const createHeight = useHeightUtility(s);
+ createHeight(["@1", "@2"]);
+
+ const css = consumeCSS(s.root, s.options);
+ expect(css).toContain("._height\\:1 {");
+ expect(css).toContain("height: calc(var(--spacing, 1rem) * 1);");
+ expect(css).toContain("._height\\:2 {");
+ expect(css).toContain("height: calc(var(--spacing, 1rem) * 2);");
+ });
});
describe("useMinHeightUtility", () => {
diff --git a/theme/src/utilities/sizing/useHeightUtility.ts b/theme/src/utilities/sizing/useHeightUtility.ts
index 7b1ca953..cc04783b 100644
--- a/theme/src/utilities/sizing/useHeightUtility.ts
+++ b/theme/src/utilities/sizing/useHeightUtility.ts
@@ -1,22 +1,32 @@
-import { createUseUtility } from "../../utils";
+import { createUseSpacingUtility, createUseUtility } from "../../utils";
/**
- * Create height utility classes.
+ * Create height utility classes with multiplier support.
*
* @example
* ```typescript
* const s = styleframe();
- * useHeightUtility(s, {
+ * const createHeight = useHeightUtility(s, {
* full: '100%',
* screen: '100vh',
* auto: 'auto',
* '1/2': '50%',
* });
+ *
+ * // Add multiplier values (with @ prefix):
+ * createHeight(["@1", "@2", "@4"]);
+ * // Generates:
+ * // ._height:1 { height: calc(var(--spacing) * 1); }
+ * // ._height:2 { height: calc(var(--spacing) * 2); }
+ * // ._height:4 { height: calc(var(--spacing) * 4); }
* ```
*/
-export const useHeightUtility = createUseUtility("height", ({ value }) => ({
- height: value,
-}));
+export const useHeightUtility = createUseSpacingUtility(
+ "height",
+ ({ value }) => ({
+ height: value,
+ }),
+);
/**
* Create min-height utility classes.
diff --git a/theme/src/utilities/sizing/useWidthUtility.test.ts b/theme/src/utilities/sizing/useWidthUtility.test.ts
index 05f6c90f..d6fda044 100644
--- a/theme/src/utilities/sizing/useWidthUtility.test.ts
+++ b/theme/src/utilities/sizing/useWidthUtility.test.ts
@@ -115,6 +115,18 @@ describe("useWidthUtility", () => {
expect(typeof creator).toBe("function");
});
+
+ it("should support spacing multiplier values", () => {
+ const s = styleframe();
+ const createWidth = useWidthUtility(s);
+ createWidth(["@1", "@2"]);
+
+ const css = consumeCSS(s.root, s.options);
+ expect(css).toContain("._width\\:1 {");
+ expect(css).toContain("width: calc(var(--spacing, 1rem) * 1);");
+ expect(css).toContain("._width\\:2 {");
+ expect(css).toContain("width: calc(var(--spacing, 1rem) * 2);");
+ });
});
describe("useMinWidthUtility", () => {
diff --git a/theme/src/utilities/sizing/useWidthUtility.ts b/theme/src/utilities/sizing/useWidthUtility.ts
index f27e25b2..be7972d2 100644
--- a/theme/src/utilities/sizing/useWidthUtility.ts
+++ b/theme/src/utilities/sizing/useWidthUtility.ts
@@ -1,23 +1,33 @@
-import { createUseUtility } from "../../utils";
+import { createUseSpacingUtility, createUseUtility } from "../../utils";
/**
- * Create width utility classes.
+ * Create width utility classes with multiplier support.
*
* @example
* ```typescript
* const s = styleframe();
- * useWidthUtility(s, {
+ * const createWidth = useWidthUtility(s, {
* full: '100%',
* screen: '100vw',
* auto: 'auto',
* '1/2': '50%',
* '1/3': '33.333333%',
* });
+ *
+ * // Add multiplier values (with @ prefix):
+ * createWidth(["@1", "@2", "@4"]);
+ * // Generates:
+ * // ._width:1 { width: calc(var(--spacing) * 1); }
+ * // ._width:2 { width: calc(var(--spacing) * 2); }
+ * // ._width:4 { width: calc(var(--spacing) * 4); }
* ```
*/
-export const useWidthUtility = createUseUtility("width", ({ value }) => ({
- width: value,
-}));
+export const useWidthUtility = createUseSpacingUtility(
+ "width",
+ ({ value }) => ({
+ width: value,
+ }),
+);
/**
* Create min-width utility classes.
diff --git a/theme/src/utilities/transitions-animation/useAnimationUtility.test.ts b/theme/src/utilities/transitions-animation/useAnimationUtility.test.ts
index 1e8f56ee..61d1e471 100644
--- a/theme/src/utilities/transitions-animation/useAnimationUtility.test.ts
+++ b/theme/src/utilities/transitions-animation/useAnimationUtility.test.ts
@@ -1,7 +1,13 @@
import type { Utility } from "@styleframe/core";
import { isUtility, styleframe } from "@styleframe/core";
import { consumeCSS } from "@styleframe/transpiler";
-import { useAnimationUtility } from "./useAnimationUtility";
+import {
+ useAnimationUtility,
+ useAnimationNameUtility,
+ useAnimationDurationUtility,
+ useAnimationTimingFunctionUtility,
+ useAnimationIterationCountUtility,
+} from "./useAnimationUtility";
describe("useAnimationUtility", () => {
it("should create utility instances with provided values", () => {
@@ -40,3 +46,163 @@ describe("useAnimationUtility", () => {
expect(s.root.children).toHaveLength(0);
});
});
+
+describe("useAnimationNameUtility", () => {
+ it("should create utility instances with provided values", () => {
+ const s = styleframe();
+ useAnimationNameUtility(s, { "fade-in": "fade-in" });
+
+ const utilities = s.root.children.filter(
+ (u): u is Utility => isUtility(u) && u.name === "animation-name",
+ );
+ expect(utilities).toHaveLength(1);
+ });
+
+ it("should set correct declarations", () => {
+ const s = styleframe();
+ useAnimationNameUtility(s, { "fade-in": "fade-in" });
+
+ const utility = s.root.children[0] as Utility;
+ expect(utility.declarations).toEqual({
+ animationName: "fade-in",
+ });
+ });
+
+ it("should compile to correct CSS output", () => {
+ const s = styleframe();
+ useAnimationNameUtility(s, { "fade-in": "fade-in" });
+
+ const css = consumeCSS(s.root, s.options);
+ expect(css).toContain("._animation-name\\:fade-in {");
+ expect(css).toContain("animation-name: fade-in;");
+ });
+
+ it("should handle empty values object", () => {
+ const s = styleframe();
+ useAnimationNameUtility(s, {});
+
+ expect(s.root.children).toHaveLength(0);
+ });
+});
+
+describe("useAnimationDurationUtility", () => {
+ it("should create utility instances with provided values", () => {
+ const s = styleframe();
+ useAnimationDurationUtility(s, { "200ms": "200ms" });
+
+ const utilities = s.root.children.filter(
+ (u): u is Utility => isUtility(u) && u.name === "animation-duration",
+ );
+ expect(utilities).toHaveLength(1);
+ });
+
+ it("should set correct declarations", () => {
+ const s = styleframe();
+ useAnimationDurationUtility(s, { "200ms": "200ms" });
+
+ const utility = s.root.children[0] as Utility;
+ expect(utility.declarations).toEqual({
+ animationDuration: "200ms",
+ });
+ });
+
+ it("should compile to correct CSS output", () => {
+ const s = styleframe();
+ useAnimationDurationUtility(s, { "200ms": "200ms" });
+
+ const css = consumeCSS(s.root, s.options);
+ expect(css).toContain("._animation-duration\\:200ms {");
+ expect(css).toContain("animation-duration: 200ms;");
+ });
+
+ it("should handle empty values object", () => {
+ const s = styleframe();
+ useAnimationDurationUtility(s, {});
+
+ expect(s.root.children).toHaveLength(0);
+ });
+});
+
+describe("useAnimationTimingFunctionUtility", () => {
+ it("should create utility instances with provided values", () => {
+ const s = styleframe();
+ useAnimationTimingFunctionUtility(s, {
+ "ease-in-out": "ease-in-out",
+ });
+
+ const utilities = s.root.children.filter(
+ (u): u is Utility =>
+ isUtility(u) && u.name === "animation-timing-function",
+ );
+ expect(utilities).toHaveLength(1);
+ });
+
+ it("should set correct declarations", () => {
+ const s = styleframe();
+ useAnimationTimingFunctionUtility(s, {
+ "ease-in-out": "ease-in-out",
+ });
+
+ const utility = s.root.children[0] as Utility;
+ expect(utility.declarations).toEqual({
+ animationTimingFunction: "ease-in-out",
+ });
+ });
+
+ it("should compile to correct CSS output", () => {
+ const s = styleframe();
+ useAnimationTimingFunctionUtility(s, {
+ "ease-in-out": "ease-in-out",
+ });
+
+ const css = consumeCSS(s.root, s.options);
+ expect(css).toContain("._animation-timing-function\\:ease-in-out {");
+ expect(css).toContain("animation-timing-function: ease-in-out;");
+ });
+
+ it("should handle empty values object", () => {
+ const s = styleframe();
+ useAnimationTimingFunctionUtility(s, {});
+
+ expect(s.root.children).toHaveLength(0);
+ });
+});
+
+describe("useAnimationIterationCountUtility", () => {
+ it("should create utility instances with provided values", () => {
+ const s = styleframe();
+ useAnimationIterationCountUtility(s, { infinite: "infinite" });
+
+ const utilities = s.root.children.filter(
+ (u): u is Utility =>
+ isUtility(u) && u.name === "animation-iteration-count",
+ );
+ expect(utilities).toHaveLength(1);
+ });
+
+ it("should set correct declarations", () => {
+ const s = styleframe();
+ useAnimationIterationCountUtility(s, { infinite: "infinite" });
+
+ const utility = s.root.children[0] as Utility;
+ expect(utility.declarations).toEqual({
+ animationIterationCount: "infinite",
+ });
+ });
+
+ it("should compile to correct CSS output", () => {
+ const s = styleframe();
+ useAnimationIterationCountUtility(s, { infinite: "infinite" });
+
+ const css = consumeCSS(s.root, s.options);
+ expect(css).toContain("._animation-iteration-count\\:infinite {");
+ expect(css).toContain("animation-iteration-count: infinite;");
+ });
+
+ it("should handle empty values object", () => {
+ const s = styleframe();
+ useAnimationIterationCountUtility(s, {});
+
+ expect(s.root.children).toHaveLength(0);
+ });
+});
diff --git a/theme/src/utilities/transitions-animation/useAnimationUtility.ts b/theme/src/utilities/transitions-animation/useAnimationUtility.ts
index 8345c869..2816a239 100644
--- a/theme/src/utilities/transitions-animation/useAnimationUtility.ts
+++ b/theme/src/utilities/transitions-animation/useAnimationUtility.ts
@@ -18,3 +18,45 @@ export const useAnimationUtility = createUseUtility(
}),
{ defaults: animationValues },
);
+
+/**
+ * Create animation-name utility classes.
+ */
+export const useAnimationNameUtility = createUseUtility(
+ "animation-name",
+ ({ value }) => ({
+ animationName: value,
+ }),
+);
+
+/**
+ * Create animation-duration utility classes.
+ */
+export const useAnimationDurationUtility = createUseUtility(
+ "animation-duration",
+ ({ value }) => ({
+ animationDuration: value,
+ }),
+ { namespace: "duration" },
+);
+
+/**
+ * Create animation-timing-function utility classes.
+ */
+export const useAnimationTimingFunctionUtility = createUseUtility(
+ "animation-timing-function",
+ ({ value }) => ({
+ animationTimingFunction: value,
+ }),
+ { namespace: "easing" },
+);
+
+/**
+ * Create animation-iteration-count utility classes.
+ */
+export const useAnimationIterationCountUtility = createUseUtility(
+ "animation-iteration-count",
+ ({ value }) => ({
+ animationIterationCount: value,
+ }),
+);