Skip to content
Open
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
1 change: 1 addition & 0 deletions src/baklava.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { default as BlRadioGroup } from "./components/radio-group/bl-radio-group
export { default as BlRadio } from "./components/radio-group/radio/bl-radio";
export { default as BlSelect } from "./components/select/bl-select";
export { default as BlSelectOption } from "./components/select/option/bl-select-option";
export { default as BlSkeleton } from "./components/skeleton/bl-skeleton";
export { default as BlSpinner } from "./components/spinner/bl-spinner";
export { default as BlSplitButton } from "./components/split-button/bl-split-button";
export { default as BlStepper } from "./components/stepper/bl-stepper";
Expand Down
58 changes: 58 additions & 0 deletions src/components/skeleton/bl-skeleton.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
:host {
display: block;
}

.skeleton {
--bg-color: var(--bl-skeleton-bg-color, var(--bl-color-neutral-lightest));
--highlight-color: var(--bl-skeleton-highlight-color, var(--bl-color-neutral-full));
--radius: var(--bl-skeleton-radius, var(--bl-border-radius-s));

background-color: var(--bg-color);
border-radius: var(--radius);
width: 100%;
height: var(--bl-size-m);
overflow: hidden;
position: relative;
}

:host([variant="circle"]) .skeleton {
--radius: var(--bl-border-radius-circle);

width: var(--bl-size-3xl);
height: var(--bl-size-3xl);
}

:host([variant="text"]) .skeleton {
height: var(--bl-size-xs);
border-radius: var(--bl-border-radius-xs);
}

:host([effect="pulse"]) .skeleton {
animation: pulse 1.5s ease-in-out infinite;
}

:host([effect="wave"]) .skeleton::after {
content: "";
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, var(--highlight-color), transparent);
animation: wave 1.6s linear infinite;
}

@keyframes pulse {
0%,
100% {
opacity: 1;
}

50% {
opacity: 0.4;
}
}

@keyframes wave {
100% {
transform: translateX(100%);
}
}
172 changes: 172 additions & 0 deletions src/components/skeleton/bl-skeleton.stories.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs';
import { html } from 'lit';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';

<Meta
title="Components/Skeleton"
component="bl-skeleton"
argTypes={{
variant: {
options: ['rect', 'circle', 'text'],
control: { type: 'select' }
},
effect: {
options: ['pulse', 'wave', 'none'],
control: { type: 'select' }
},
width: {
control: 'text'
},
height: {
control: 'text'
},
}}
/>

export const SkeletonTemplate = (args) => html`<bl-skeleton
variant=${ifDefined(args.variant)}
effect=${ifDefined(args.effect)}
width=${ifDefined(args.width)}
height=${ifDefined(args.height)}
style=${ifDefined(args.styles ? styleMap(args.styles) : undefined)}
></bl-skeleton>`;

export const VariantTemplate = () => html`
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 400px;">
<div>
<p style="margin: 0 0 8px; font-weight: 600;">Rect (Default)</p>
<bl-skeleton></bl-skeleton>
</div>
<div>
<p style="margin: 0 0 8px; font-weight: 600;">Circle</p>
<bl-skeleton variant="circle"></bl-skeleton>
</div>
<div>
<p style="margin: 0 0 8px; font-weight: 600;">Text</p>
<bl-skeleton variant="text"></bl-skeleton>
<bl-skeleton variant="text" style="margin-top: 8px;" width="80%"></bl-skeleton>
<bl-skeleton variant="text" style="margin-top: 8px;" width="60%"></bl-skeleton>
</div>
</div>
`;

export const EffectTemplate = () => html`
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 400px;">
<div>
<p style="margin: 0 0 8px; font-weight: 600;">Pulse (Default)</p>
<bl-skeleton effect="pulse"></bl-skeleton>
</div>
<div>
<p style="margin: 0 0 8px; font-weight: 600;">Wave</p>
<bl-skeleton effect="wave"></bl-skeleton>
</div>
<div>
<p style="margin: 0 0 8px; font-weight: 600;">None</p>
<bl-skeleton effect="none"></bl-skeleton>
</div>
</div>
`;

export const CustomSizeTemplate = () => html`
<div style="display: flex; flex-direction: column; gap: 16px; max-width: 600px;">
<bl-skeleton width="200px" height="20px"></bl-skeleton>
<bl-skeleton width="100%" height="40px"></bl-skeleton>
<bl-skeleton width="300px" height="120px"></bl-skeleton>
<bl-skeleton variant="circle" width="64px" height="64px"></bl-skeleton>
</div>
`;

export const CardTemplate = () => html`
<div style="display: flex; gap: 24px; flex-wrap: wrap;">
<div style="width: 300px; padding: 16px; border: 1px solid var(--bl-color-neutral-lighter); border-radius: 8px;">
<bl-skeleton width="100%" height="160px" style="margin-bottom: 16px;"></bl-skeleton>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<bl-skeleton variant="circle" width="40px" height="40px"></bl-skeleton>
<div style="flex: 1;">
<bl-skeleton variant="text" width="60%"></bl-skeleton>
<bl-skeleton variant="text" width="40%" style="margin-top: 8px;"></bl-skeleton>
</div>
</div>
<bl-skeleton variant="text"></bl-skeleton>
<bl-skeleton variant="text" width="90%" style="margin-top: 8px;"></bl-skeleton>
<bl-skeleton variant="text" width="70%" style="margin-top: 8px;"></bl-skeleton>
</div>
<div style="width: 300px; padding: 16px; border: 1px solid var(--bl-color-neutral-lighter); border-radius: 8px;">
<bl-skeleton width="100%" height="160px" effect="wave" style="margin-bottom: 16px;"></bl-skeleton>
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<bl-skeleton variant="circle" width="40px" height="40px" effect="wave"></bl-skeleton>
<div style="flex: 1;">
<bl-skeleton variant="text" width="60%" effect="wave"></bl-skeleton>
<bl-skeleton variant="text" width="40%" effect="wave" style="margin-top: 8px;"></bl-skeleton>
</div>
</div>
<bl-skeleton variant="text" effect="wave"></bl-skeleton>
<bl-skeleton variant="text" width="90%" effect="wave" style="margin-top: 8px;"></bl-skeleton>
<bl-skeleton variant="text" width="70%" effect="wave" style="margin-top: 8px;"></bl-skeleton>
</div>
</div>
`;

# Skeleton

Skeleton component provides a placeholder preview of content before data is loaded. It reduces the perceived loading time and provides a better user experience.

## Variants

Skeleton has 3 variants: `rect` (default), `circle`, and `text`.

- **rect** — General-purpose rectangular placeholder. Use for images, cards, and content blocks.
- **circle** — Circular placeholder. Use for avatars and profile pictures.
- **text** — Thin line placeholder. Use for text content and paragraphs.

<Canvas>
<Story name="Variants">
{VariantTemplate.bind({})}
</Story>
</Canvas>

## Animation Effects

Skeleton supports 3 animation effects: `pulse` (default), `wave`, and `none`.

<Canvas>
<Story name="Effects">
{EffectTemplate.bind({})}
</Story>
</Canvas>

## Custom Sizes

You can set custom `width` and `height` properties to control the skeleton dimensions.

<Canvas>
<Story name="Custom Sizes">
{CustomSizeTemplate.bind({})}
</Story>
</Canvas>

## Card Loading Example

Combine multiple skeleton elements to create realistic loading placeholders for complex layouts.

<Canvas>
<Story name="Card Loading">
{CardTemplate.bind({})}
</Story>
</Canvas>

## Customizing Colors

You can customize the skeleton appearance using CSS custom properties:

```css
bl-skeleton {
--bl-skeleton-bg-color: #e0e0e0;
--bl-skeleton-highlight-color: #f5f5f5;
}
```

## Reference

<ArgsTable of="bl-skeleton" />
116 changes: 116 additions & 0 deletions src/components/skeleton/bl-skeleton.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { assert, expect, fixture, html } from "@open-wc/testing";
import BlSkeleton from "./bl-skeleton";

import type typeOfBlSkeleton from "./bl-skeleton";

describe("bl-skeleton", () => {
it("is defined", () => {
const el = document.createElement("bl-skeleton");

assert.instanceOf(el, BlSkeleton);
});

it("renders with default values", async () => {
const el = await fixture<typeOfBlSkeleton>(html`<bl-skeleton></bl-skeleton>`);

assert.shadowDom.equal(
el,
"<div class=\"skeleton\" role=\"presentation\" aria-hidden=\"true\" style=\"\"></div>"
);
});

it("has correct default property values", async () => {
const el = await fixture<typeOfBlSkeleton>(html`<bl-skeleton></bl-skeleton>`);

expect(el.variant).to.equal("rect");
expect(el.effect).to.equal("pulse");
expect(el.width).to.be.undefined;
expect(el.height).to.be.undefined;
});

it("reflects variant attribute", async () => {
const el = await fixture<typeOfBlSkeleton>(
html`<bl-skeleton variant="circle"></bl-skeleton>`
);

expect(el.variant).to.equal("circle");
expect(el.getAttribute("variant")).to.equal("circle");
});

it("reflects effect attribute", async () => {
const el = await fixture<typeOfBlSkeleton>(
html`<bl-skeleton effect="wave"></bl-skeleton>`
);

expect(el.effect).to.equal("wave");
expect(el.getAttribute("effect")).to.equal("wave");
});

it("applies custom width via inline style", async () => {
const el = await fixture<typeOfBlSkeleton>(
html`<bl-skeleton width="200px"></bl-skeleton>`
);
const skeleton = el.shadowRoot!.querySelector<HTMLElement>(".skeleton")!;

expect(skeleton.style.width).to.equal("200px");
});

it("applies custom height via inline style", async () => {
const el = await fixture<typeOfBlSkeleton>(
html`<bl-skeleton height="50px"></bl-skeleton>`
);
const skeleton = el.shadowRoot!.querySelector<HTMLElement>(".skeleton")!;

expect(skeleton.style.height).to.equal("50px");
});

it("applies both width and height", async () => {
const el = await fixture<typeOfBlSkeleton>(
html`<bl-skeleton width="300px" height="100px"></bl-skeleton>`
);
const skeleton = el.shadowRoot!.querySelector<HTMLElement>(".skeleton")!;

expect(skeleton.style.width).to.equal("300px");
expect(skeleton.style.height).to.equal("100px");
});

it("does not set inline width/height when not provided", async () => {
const el = await fixture<typeOfBlSkeleton>(html`<bl-skeleton></bl-skeleton>`);
const skeleton = el.shadowRoot!.querySelector<HTMLElement>(".skeleton")!;

expect(skeleton.style.width).to.equal("");
expect(skeleton.style.height).to.equal("");
});

it("sets role=presentation and aria-hidden=true for accessibility", async () => {
const el = await fixture<typeOfBlSkeleton>(html`<bl-skeleton></bl-skeleton>`);
const skeleton = el.shadowRoot!.querySelector(".skeleton")!;

expect(skeleton.getAttribute("role")).to.equal("presentation");
expect(skeleton.getAttribute("aria-hidden")).to.equal("true");
});

it("supports text variant", async () => {
const el = await fixture<typeOfBlSkeleton>(
html`<bl-skeleton variant="text"></bl-skeleton>`
);

expect(el.variant).to.equal("text");
expect(el.getAttribute("variant")).to.equal("text");
});

it("supports none effect", async () => {
const el = await fixture<typeOfBlSkeleton>(
html`<bl-skeleton effect="none"></bl-skeleton>`
);

expect(el.effect).to.equal("none");
expect(el.getAttribute("effect")).to.equal("none");
});

it("renders as block-level element by default", async () => {
const el = await fixture<typeOfBlSkeleton>(html`<bl-skeleton></bl-skeleton>`);

expect(getComputedStyle(el).display).to.equal("block");
});
});
Loading