diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f9ed920..5672d40a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@ Changelog
=========
+## v10.1.5 *(2026-03-21)*
+- New: skeleton loader components.
+- New: icon `photo`.
+
+
## v10.1.4 *(2026-03-19)*
- Grid column cell renderer now accepts svelte snippets.
diff --git a/docs/components/index.ts b/docs/components/index.ts
index 3521bcea..d4d4e21e 100644
--- a/docs/components/index.ts
+++ b/docs/components/index.ts
@@ -21,6 +21,7 @@ export { ColorPalette } from './color-palette';
export { Icon } from './icon';
export { Menu } from './menu';
export { Shortcut } from './shortcut';
+export { Skeleton } from './skeleton';
export { Splitter } from './splitter';
export { Tag } from './tag';
export { ThemeSwitcher } from './theme-switcher';
diff --git a/docs/components/skeleton/Skeleton.css b/docs/components/skeleton/Skeleton.css
new file mode 100644
index 00000000..ec59a5aa
--- /dev/null
+++ b/docs/components/skeleton/Skeleton.css
@@ -0,0 +1,4 @@
+
+.skeleton-cards {
+ gap: 2rem;
+}
diff --git a/docs/components/skeleton/Skeleton.svelte b/docs/components/skeleton/Skeleton.svelte
new file mode 100644
index 00000000..47832820
--- /dev/null
+++ b/docs/components/skeleton/Skeleton.svelte
@@ -0,0 +1,138 @@
+
Skeleton
+
+Animated placeholder for loading states.
+
+
+Skeleton Shapes
+
+Rectangle
+
+
+Circle
+
+
+Text
+
+
+Image
+
+
+
+
+Skeleton Layouts
+
+SkeletonHStack
+
+
+
+
+
+
+
+
+SkeletonVStack
+
+
+
+
+
+
+
+SkeletonGrid
+
+
+
+
+
+
+
+
+
+
+
+
+Custom build
+
+Cards list
+
+
+
+
+
+
+
+
+
+
+
+
+Cards grid
+{#snippet card()}
+
+
+
+
+
+
+
+{/snippet}
+
+
+ {@render card()}
+ {@render card()}
+ {@render card()}
+ {@render card()}
+ {@render card()}
+ {@render card()}
+
+
+
+
+{`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`}
+
+
+
+
+
diff --git a/docs/components/skeleton/index.ts b/docs/components/skeleton/index.ts
new file mode 100644
index 00000000..9a7f3572
--- /dev/null
+++ b/docs/components/skeleton/index.ts
@@ -0,0 +1 @@
+export { default as Skeleton } from './Skeleton.svelte';
diff --git a/docs/nav/Nav.svelte b/docs/nav/Nav.svelte
index c2d6007b..bcf3ed3e 100644
--- a/docs/nav/Nav.svelte
+++ b/docs/nav/Nav.svelte
@@ -53,6 +53,7 @@
+
diff --git a/docs/pages/changelog.html b/docs/pages/changelog.html
index 1a313d61..667efc55 100644
--- a/docs/pages/changelog.html
+++ b/docs/pages/changelog.html
@@ -1,4 +1,9 @@
Changelog
+v10.1.5 (2026-03-21)
+
+- New: skeleton loader components.
+- New: icon
photo.
+
v10.1.4 (2026-03-19)
- Grid column cell renderer now accepts svelte snippets.
diff --git a/src/icon/icons.ts b/src/icon/icons.ts
index d3a3b067..9948546e 100644
--- a/src/icon/icons.ts
+++ b/src/icon/icons.ts
@@ -53,6 +53,7 @@ export const icons = {
meatballs: 'dots-vertical">
+```
+-->
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/skeleton/layouts/HStack.svelte b/src/skeleton/layouts/HStack.svelte
new file mode 100644
index 00000000..e97e9295
--- /dev/null
+++ b/src/skeleton/layouts/HStack.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/skeleton/layouts/VStack.svelte b/src/skeleton/layouts/VStack.svelte
new file mode 100644
index 00000000..460e192d
--- /dev/null
+++ b/src/skeleton/layouts/VStack.svelte
@@ -0,0 +1,25 @@
+
+
+
+ {@render children?.()}
+
+
+
diff --git a/src/skeleton/layouts/index.ts b/src/skeleton/layouts/index.ts
new file mode 100644
index 00000000..55037d2f
--- /dev/null
+++ b/src/skeleton/layouts/index.ts
@@ -0,0 +1,3 @@
+export { default as SkeletonHStack } from './HStack.svelte';
+export { default as SkeletonVStack } from './VStack.svelte';
+export { default as SkeletonGrid } from './Grid.svelte';
diff --git a/src/skeleton/layouts/layouts.css b/src/skeleton/layouts/layouts.css
new file mode 100644
index 00000000..a22ed093
--- /dev/null
+++ b/src/skeleton/layouts/layouts.css
@@ -0,0 +1,7 @@
+.skeleton-hstack,
+.skeleton-vstack { display: flex; gap: 0.7em; flex: 1; }
+
+.skeleton-hstack { flex-flow: row nowrap; }
+.skeleton-vstack { flex-flow: column nowrap; }
+
+.skeleton-grid { display: grid; gap: 0.7em; }
diff --git a/src/skeleton/shapes/Circle.svelte b/src/skeleton/shapes/Circle.svelte
new file mode 100644
index 00000000..f30505ef
--- /dev/null
+++ b/src/skeleton/shapes/Circle.svelte
@@ -0,0 +1,26 @@
+
+
+
+
diff --git a/src/skeleton/shapes/Image.svelte b/src/skeleton/shapes/Image.svelte
new file mode 100644
index 00000000..622b6323
--- /dev/null
+++ b/src/skeleton/shapes/Image.svelte
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
diff --git a/src/skeleton/shapes/Rect.svelte b/src/skeleton/shapes/Rect.svelte
new file mode 100644
index 00000000..f785b26d
--- /dev/null
+++ b/src/skeleton/shapes/Rect.svelte
@@ -0,0 +1,30 @@
+
+
+{@render children?.()}
+
+
diff --git a/src/skeleton/shapes/Text.svelte b/src/skeleton/shapes/Text.svelte
new file mode 100644
index 00000000..684e0a15
--- /dev/null
+++ b/src/skeleton/shapes/Text.svelte
@@ -0,0 +1,32 @@
+
+
+
+ {#each Array(size) as _, i (i)}
+
+ {/each}
+
+
+
diff --git a/src/skeleton/shapes/index.ts b/src/skeleton/shapes/index.ts
new file mode 100644
index 00000000..17a566cb
--- /dev/null
+++ b/src/skeleton/shapes/index.ts
@@ -0,0 +1,4 @@
+export { default as SkeletonRect } from './Rect.svelte';
+export { default as SkeletonCircle } from './Circle.svelte';
+export { default as SkeletonImage } from './Image.svelte';
+export { default as SkeletonText } from './Text.svelte';
diff --git a/src/skeleton/shapes/shapes.css b/src/skeleton/shapes/shapes.css
new file mode 100644
index 00000000..25d72374
--- /dev/null
+++ b/src/skeleton/shapes/shapes.css
@@ -0,0 +1,65 @@
+.skeleton-shape {
+ position: relative;
+ border-radius: var(--ui-border-radius-l);
+ background-color: var(--ui-color-secondary-semi);
+ overflow: clip;
+}
+
+.skeleton-shape::after {
+ content: '';
+ display: block;
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ background-attachment: fixed;
+ background-repeat: no-repeat;
+ background-image: linear-gradient(119deg, #fff0 0%, #fff0 25%, #fffa 50%, #fff0 75%, #fff0 100%);
+ background-size: 100% 100%;
+ background-position: -100vw 0;
+ animation: skeleton-animation 2000ms infinite;
+}
+
+@keyframes skeleton-animation {
+ to { background-position: 100vw 0; }
+}
+
+
+/* vertical:
+.skeleton-item2::after {
+ background-image: linear-gradient(90deg, #fff0 0%, #fff9 50%, #fff0 100%);
+ background-size: 50% 100%;
+ background-position: -100% 0;
+ animation: skeleton-animation2 2000ms infinite;
+}
+@keyframes skeleton-animation2 {
+ to { background-position: 200% 0; }
+} */
+
+
+.skeleton-circle { border-radius: 50%; aspect-ratio: 1; width: 3em; }
+.skeleton-rect { width: auto; }
+
+
+.skeleton-img {
+ display: grid;
+ place-items: center;
+ min-height: 3em;
+ aspect-ratio: 16/9;
+}
+
+.skeleton-img svg {
+ width: 30%;
+ height: 30%;
+ color: var(--ui-color-text-2);
+ opacity: 0.3;
+}
+
+
+.skeleton-line { height: 0.8em; width: 75%; min-height: unset; }
+.skeleton-line:nth-child(6n+1) { width: 100%; }
+.skeleton-line:nth-child(6n+2) { width: 92%; }
+.skeleton-line:nth-child(6n+3) { width: 85%; }
+.skeleton-line:nth-child(6n+4) { width: 78%; }
+.skeleton-line:nth-child(6n+5) { width: 95%; }
+.skeleton-line:nth-child(6n+6) { width: 88%; }
diff --git a/src/skeleton/types.ts b/src/skeleton/types.ts
new file mode 100644
index 00000000..683b15ca
--- /dev/null
+++ b/src/skeleton/types.ts
@@ -0,0 +1,31 @@
+import type { ComponentProps } from '../types';
+
+
+// Layouts & Containers
+export type SkeletonContainer = Pick;
+
+export interface SkeletonGridProps extends SkeletonContainer {
+ size?: number | string;
+}
+
+export interface SkeletonRectProps extends SkeletonContainer {
+ width?: number | string;
+ height?: number | string;
+}
+
+
+// Shapes
+type SkeletonShape = Pick;
+
+export interface SkeletonCircleProps extends SkeletonShape {
+ size?: number | string;
+}
+
+export interface SkeletonTextProps extends SkeletonShape {
+ size?: number | string;
+}
+
+export interface SkeletonImageProps extends SkeletonShape {
+ width?: number | string;
+ height?: number | string;
+}
diff --git a/tests/Skeleton.svelte.test.ts b/tests/Skeleton.svelte.test.ts
new file mode 100644
index 00000000..d2efc1d8
--- /dev/null
+++ b/tests/Skeleton.svelte.test.ts
@@ -0,0 +1,315 @@
+import { beforeEach, expect, test } from 'vitest';
+import { mount, unmount } from 'svelte';
+import {
+ SkeletonRect,
+ SkeletonCircle,
+ SkeletonText,
+ SkeletonImage,
+ SkeletonHStack,
+ SkeletonVStack,
+ SkeletonGrid,
+} from '../src';
+import SkeletonLayout from './helpers/Skeleton.svelte';
+
+
+beforeEach(() => {
+ document.body.innerHTML = '';
+});
+
+
+test('SkeletonRect renders with default classes', async () => {
+ const component = mount(SkeletonRect, { target: document.body });
+
+ const el = document.body.querySelector('.skeleton-shape.skeleton-rect');
+ expect(el).toBeInTheDocument();
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect applies numeric width/height as pixels', async () => {
+ const component = mount(SkeletonRect, {
+ target: document.body,
+ props: { width: 200, height: 40 },
+ });
+
+ const el = document.body.querySelector('.skeleton-rect');
+ expect(el?.style.width).toBe('200px');
+ expect(el?.style.height).toBe('40px');
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect applies string width/height as-is', async () => {
+ const component = mount(SkeletonRect, {
+ target: document.body,
+ props: { width: '50%', height: '4em' },
+ });
+
+ const el = document.body.querySelector('.skeleton-rect');
+ expect(el?.style.width).toBe('50%');
+ expect(el?.style.height).toBe('4em');
+
+ await unmount(component);
+});
+
+
+test('SkeletonRect applies custom class', async () => {
+ const component = mount(SkeletonRect, {
+ target: document.body,
+ props: { class: 'my-rect' },
+ });
+
+ const el = document.body.querySelector('.skeleton-rect');
+ expect(el).toHaveClass('my-rect');
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle renders with default size 60px', async () => {
+ const component = mount(SkeletonCircle, { target: document.body });
+
+ const el = document.body.querySelector('.skeleton-shape.skeleton-circle');
+ expect(el).toBeInTheDocument();
+ expect(el?.style.width).toBe('60px');
+ expect(el?.style.height).toBe('60px');
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle applies numeric size as pixels', async () => {
+ const component = mount(SkeletonCircle, {
+ target: document.body,
+ props: { size: 80 },
+ });
+
+ const el = document.body.querySelector('.skeleton-circle');
+ expect(el?.style.width).toBe('80px');
+ expect(el?.style.height).toBe('80px');
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle applies string size as-is', async () => {
+ const component = mount(SkeletonCircle, {
+ target: document.body,
+ props: { size: '5em' },
+ });
+
+ const el = document.body.querySelector('.skeleton-circle');
+ expect(el?.style.width).toBe('5em');
+ expect(el?.style.height).toBe('5em');
+
+ await unmount(component);
+});
+
+
+test('SkeletonCircle applies custom class', async () => {
+ const component = mount(SkeletonCircle, {
+ target: document.body,
+ props: { class: 'avatar' },
+ });
+
+ const el = document.body.querySelector('.skeleton-circle');
+ expect(el).toHaveClass('avatar');
+
+ await unmount(component);
+});
+
+
+test('SkeletonText renders default 3 lines wrapped in VStack', async () => {
+ const component = mount(SkeletonText, { target: document.body });
+
+ const wrapper = document.body.querySelector('.skeleton-vstack.skeleton-text');
+ expect(wrapper).toBeInTheDocument();
+
+ const lines = wrapper.querySelectorAll('.skeleton-rect.skeleton-line');
+ expect(lines.length).toBe(3);
+
+ await unmount(component);
+});
+
+
+test('SkeletonText renders the requested number of lines', async () => {
+ const component = mount(SkeletonText, {
+ target: document.body,
+ props: { size: 7 },
+ });
+
+ const lines = document.body.querySelectorAll('.skeleton-text .skeleton-line');
+ expect(lines.length).toBe(7);
+
+ await unmount(component);
+});
+
+
+test('SkeletonText applies custom class on the wrapper', async () => {
+ const component = mount(SkeletonText, {
+ target: document.body,
+ props: { class: 'paragraph' },
+ });
+
+ const wrapper = document.body.querySelector('.skeleton-text');
+ expect(wrapper).toHaveClass('paragraph');
+
+ await unmount(component);
+});
+
+
+test('SkeletonImage renders a rect with photo icon and default dimensions', async () => {
+ const component = mount(SkeletonImage, { target: document.body });
+
+ const el = document.body.querySelector('.skeleton-rect.skeleton-img');
+ expect(el).toBeInTheDocument();
+ expect(el?.style.width).toBe('100%');
+ expect(el?.style.height).toBe('120px');
+
+ const icon = el.querySelector('svg');
+ expect(icon).toBeInTheDocument();
+
+ await unmount(component);
+});
+
+
+test('SkeletonImage applies custom width and height', async () => {
+ const component = mount(SkeletonImage, {
+ target: document.body,
+ props: { width: 240, height: '10em' },
+ });
+
+ const el = document.body.querySelector('.skeleton-img');
+ expect(el?.style.width).toBe('240px');
+ expect(el?.style.height).toBe('10em');
+
+ await unmount(component);
+});
+
+
+test('SkeletonImage applies custom class', async () => {
+ const component = mount(SkeletonImage, {
+ target: document.body,
+ props: { class: 'hero' },
+ });
+
+ const el = document.body.querySelector('.skeleton-img');
+ expect(el).toHaveClass('hero');
+
+ await unmount(component);
+});
+
+
+test('SkeletonHStack renders container with custom class', async () => {
+ const component = mount(SkeletonHStack, {
+ target: document.body,
+ props: { class: 'row' },
+ });
+
+ const el = document.body.querySelector('.skeleton-hstack');
+ expect(el).toBeInTheDocument();
+ expect(el).toHaveClass('row');
+
+ await unmount(component);
+});
+
+
+test('SkeletonHStack renders its children', async () => {
+ const component = mount(SkeletonLayout, {
+ target: document.body,
+ props: { layout: 'hstack', class: 'row' },
+ });
+
+ const el = document.body.querySelector('.skeleton-hstack.row');
+ expect(el).toBeInTheDocument();
+
+ const children = el.querySelectorAll(':scope > .child');
+ expect(children.length).toBe(2);
+
+ await unmount(component);
+});
+
+
+test('SkeletonVStack renders container with custom class', async () => {
+ const component = mount(SkeletonVStack, {
+ target: document.body,
+ props: { class: 'col' },
+ });
+
+ const el = document.body.querySelector('.skeleton-vstack');
+ expect(el).toBeInTheDocument();
+ expect(el).toHaveClass('col');
+
+ await unmount(component);
+});
+
+
+test('SkeletonVStack renders its children', async () => {
+ const component = mount(SkeletonLayout, {
+ target: document.body,
+ props: { layout: 'vstack', class: 'col' },
+ });
+
+ const el = document.body.querySelector('.skeleton-vstack.col');
+ expect(el).toBeInTheDocument();
+
+ const children = el.querySelectorAll(':scope > .child');
+ expect(children.length).toBe(2);
+
+ await unmount(component);
+});
+
+
+test('SkeletonGrid renders with default 150px min column width', async () => {
+ const component = mount(SkeletonGrid, { target: document.body });
+
+ const el = document.body.querySelector('.skeleton-grid');
+ expect(el).toBeInTheDocument();
+ expect(el?.getAttribute('style')).toContain('minmax(150px, 1fr)');
+
+ await unmount(component);
+});
+
+
+test('SkeletonGrid accepts numeric size as pixels', async () => {
+ const component = mount(SkeletonGrid, {
+ target: document.body,
+ props: { size: 220 },
+ });
+
+ const el = document.body.querySelector('.skeleton-grid');
+ expect(el?.getAttribute('style')).toContain('minmax(220px, 1fr)');
+
+ await unmount(component);
+});
+
+
+test('SkeletonGrid accepts string size as-is', async () => {
+ const component = mount(SkeletonGrid, {
+ target: document.body,
+ props: { size: '12rem' },
+ });
+
+ const el = document.body.querySelector('.skeleton-grid');
+ expect(el?.getAttribute('style')).toContain('minmax(12rem, 1fr)');
+
+ await unmount(component);
+});
+
+
+test('SkeletonGrid renders its children', async () => {
+ const component = mount(SkeletonLayout, {
+ target: document.body,
+ props: { layout: 'grid', class: 'gallery', size: 120 },
+ });
+
+ const el = document.body.querySelector('.skeleton-grid.gallery');
+ expect(el).toBeInTheDocument();
+
+ const children = el.querySelectorAll(':scope > .child');
+ expect(children.length).toBe(3);
+
+ await unmount(component);
+});
diff --git a/tests/helpers/Skeleton.svelte b/tests/helpers/Skeleton.svelte
new file mode 100644
index 00000000..42b7437d
--- /dev/null
+++ b/tests/helpers/Skeleton.svelte
@@ -0,0 +1,34 @@
+
+
+{#if layout === 'hstack'}
+
+
+
+
+{:else if layout === 'vstack'}
+
+
+
+
+{:else}
+
+
+
+
+
+{/if}