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
5 changes: 5 additions & 0 deletions .changeset/marked-bones-shimmer-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@camp.dev/bones": minor
---

Marked bones (`data-bone`) now shimmer by default, matching auto bones and the measured overlay. Before this, `bones.css` only animated inside a `data-bone-animate` scope, so a page that mixed an `aria-busy` region with explicit `data-bone` markup showed shimmering auto bones beside frozen marked ones. Set `data-bone-animate="none"` on a bone, a wrapper, or `<body>` to keep skeletons still. `"pulse"` still switches the animation. Under `prefers-reduced-motion: reduce`, the default and the `shimmer` and `pulse` scopes all downgrade to a slow pulse, and `none` stays still. This replaces the old reduced-motion rule, which pulsed the whole `aria-busy` element on top of the bar's own animation.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b

`[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed.

Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` works on the `aria-busy` element itself or on any ancestor. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.
Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` works on the `aria-busy` element itself or on any ancestor. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, every bone shimmers, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.

## Without React

Expand Down
5 changes: 3 additions & 2 deletions apps/demo/components/animations-demo/animations-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ export function AnimationsDemo() {
title="Animations"
description={
<>
Add <code>data-bone-animate</code> to any parent element. Set it on{" "}
<code>&lt;body&gt;</code> for app-wide animation, or scope it to individual sections.
Bones shimmer by default. Set <code>data-bone-animate</code> on any parent element to
switch to <code>pulse</code> or <code>none</code>. Use it on <code>&lt;body&gt;</code> for
the whole app, or scope it to individual sections.
</>
}
>
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/content/docs/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function TagList({ pokemon }: { pokemon: Promise<Pokemon> }) {

## Skeleton animations

Skeletons are static by default. Add `data-bone-animate` to an element to animate the bones inside it. The attribute also works on a bone itself and on the `aria-busy` element of an automatic region.
Skeletons shimmer by default. Set `data-bone-animate` on an element to change the animation for the bones inside it, or to `none` to keep them still. The attribute also works on a bone itself and on the `aria-busy` element of an automatic region.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Shimmer

Expand All @@ -183,10 +183,10 @@ Bones uses `pulse` automatically when `prefers-reduced-motion: reduce` is active

### Global animation

Set it on `<body>` to animate every skeleton in your app:
Set it on `<body>` to change every skeleton in your app:

```tsx
<body data-bone-animate="shimmer">
<body data-bone-animate="pulse">
```

Individual sections can override the global animation:
Expand Down
15 changes: 8 additions & 7 deletions apps/docs/content/docs/styling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,25 +58,26 @@ One case needs a manual override: text that depends on its background for contra

## Animations

Add `data-bone-animate` to an element to animate the bones inside it. The attribute also works on a bone itself and on the `aria-busy` element of an automatic region.
Bones shimmer by default. Set `data-bone-animate` on an element to change the animation for the bones inside it. The attribute also works on a bone itself and on the `aria-busy` element of an automatic region.

- `shimmer` - horizontal highlight sweep
- `shimmer` - horizontal highlight sweep (the default)
- `pulse` - gentle opacity fade (also kicks in automatically for `prefers-reduced-motion: reduce`)
- `none` - no animation

```tsx
{/* Animate a section */}
<div data-bone-animate="shimmer">
{/* Pulse one section */}
<div data-bone-animate="pulse">
<UserCard user={fetchUser()} />
</div>

{/* Animate the entire app */}
<body data-bone-animate="shimmer">
{/* Keep every skeleton in the app still */}
<body data-bone-animate="none">
```

Speed is controlled by `--bone-duration`. Shimmer colors use `--bone-base` and `--bone-highlight`.

## Accessibility

- Skeleton elements get `aria-busy="true"` automatically.
- `pulse` animation activates for users who prefer reduced motion.
- `pulse` replaces `shimmer` for users who prefer reduced motion. `none` stays still.
- Skeleton pseudo-elements have `pointer-events: none`.
2 changes: 1 addition & 1 deletion packages/bones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b

`[data-bones-auto="off"]` opts a subtree out — useful for a status message you want to stay readable while its container skeletonizes. Explicit `data-bone` markup is left alone; `auto.css` only styles elements neither `bone()` nor a manual `data-bone` attribute has already claimed.

Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` works on the `aria-busy` element itself or on any ancestor. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.
Auto rules live in `@layer bones-auto`, so any page CSS that sets `color` on an element outranks the bone's transparent text, and that text stays visible over its skeleton bar. `data-bone-animate` works on the `aria-busy` element itself or on any ancestor. The `data-bone-animate` overrides rely on `@scope`. In a browser without `@scope`, every bone shimmers, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.

## Without React

Expand Down
9 changes: 5 additions & 4 deletions packages/bones/src/css/auto.css
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,11 @@
}

@layer bones-auto {
/* Auto bones default to shimmer. bones.css only animates inside a
[data-bone-animate] scope; a zero-markup page has no such attribute, so
the default lives here and the scopes below mirror bones.css for pages
that do set it. In those scopes, :is(:scope, :scope *) lets the attribute
/* Auto bones default to shimmer. bones.css has its own default, but its
rules match [data-bone] elements only and never the unmarked leaves
targeted here, so the default is restated for them, and the scopes below
mirror bones.css for pages that set the attribute (BON-16). In those
scopes, :is(:scope, :scope *) lets the attribute
sit on the aria-busy element itself: a scoped selector's implicit :scope
prefix matches strict descendants only (BON-17). */
[aria-busy="true"]
Expand Down
58 changes: 48 additions & 10 deletions packages/bones/src/css/bones.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,41 @@ video[data-bone="block"] {
}
}

/* Marked bones shimmer by default, the same as auto bones and the measured
overlay (BON-16). data-bone-animate="none" is the opt-out; the @scope
blocks below override this at higher specificity, so their order relative
to this rule does not matter. */
[data-bone="text"]::after,
[data-bone="block"],
[data-bone="container"]::before {
animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
background: linear-gradient(
90deg,
var(--bone-base) 25%,
var(--bone-highlight) 50%,
var(--bone-base) 75%
);
background-size: 200% 100%;
}

@media (prefers-reduced-motion: reduce) {
[data-bone="text"]::after,
[data-bone="block"],
[data-bone="container"]::before {
animation: bone-pulse 2s ease-in-out infinite;
background: var(--bone-base);
background-size: auto;
}
}

/* :is(:scope, :scope *) lets the attribute work from the bone itself, not
only from a wrapper: a scoped selector's implicit :scope prefix matches
strict descendants only, so a plain compound never matches the scope root
(BON-17). */
(BON-17). The reduced-motion override sits inside each animated scope so it
matches at the scope's own specificity; "none" has no override because none
still means none. */
@scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) {
[data-bone="text"]:is(:scope, :scope *)::after,
[data-bone="text"]:is(:scope, :scope *)::before,
[data-bone="block"]:is(:scope, :scope *),
[data-bone="container"]:is(:scope, :scope *)::before {
animation: bone-shimmer var(--bone-duration) ease-in-out infinite;
Expand All @@ -128,32 +156,42 @@ video[data-bone="block"] {
);
background-size: 200% 100%;
}

@media (prefers-reduced-motion: reduce) {
[data-bone="text"]:is(:scope, :scope *)::after,
[data-bone="block"]:is(:scope, :scope *),
[data-bone="container"]:is(:scope, :scope *)::before {
animation: bone-pulse 2s ease-in-out infinite;
background: var(--bone-base);
background-size: auto;
}
}
}

@scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) {
[data-bone="text"]:is(:scope, :scope *)::after,
[data-bone="text"]:is(:scope, :scope *)::before,
[data-bone="block"]:is(:scope, :scope *),
[data-bone="container"]:is(:scope, :scope *)::before {
animation: bone-pulse var(--bone-duration) ease-in-out infinite;
background: var(--bone-base);
background-size: auto;
}

@media (prefers-reduced-motion: reduce) {
[data-bone="text"]:is(:scope, :scope *)::after,
[data-bone="block"]:is(:scope, :scope *),
[data-bone="container"]:is(:scope, :scope *)::before {
animation: bone-pulse 2s ease-in-out infinite;
}
}
}

@scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) {
[data-bone="text"]:is(:scope, :scope *)::after,
[data-bone="text"]:is(:scope, :scope *)::before,
[data-bone="block"]:is(:scope, :scope *),
[data-bone="container"]:is(:scope, :scope *)::before {
animation: none;
background: var(--bone-base);
background-size: auto;
}
}

@media (prefers-reduced-motion: reduce) {
[aria-busy="true"] {
animation: bone-pulse 2s ease-in-out infinite;
}
}
79 changes: 79 additions & 0 deletions packages/bones/tests/bones-css.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vite-plus/test";

// ---------------------------------------------------------------------------
// bones.css reduced-motion contract
//
// Marked bones shimmer by default (BON-16), and the shimmer/pulse scopes can
// switch that. Under prefers-reduced-motion: reduce every animated path must
// downgrade to a slow pulse, and data-bone-animate="none" must stay still.
// The browser runner cannot emulate media features, so this reads the shipped
// file and pins where the override lives, the same way boundary.test.ts pins
// the overlay's shadow sheet.
// ---------------------------------------------------------------------------

const css = readFileSync(join(import.meta.dirname, "../src/css/bones.css"), "utf8").replace(
/\/\*[\s\S]*?\*\//g,
"",
);

const REDUCED = "@media (prefers-reduced-motion: reduce)";

/** The brace-balanced block that starts at the first `marker` in `source`. */
function blockAt(source: string, marker: string): string {
const start = source.indexOf(marker);
if (start === -1) throw new Error(`${marker} not found`);
const open = source.indexOf("{", start);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === "{") depth++;
else if (source[i] === "}" && --depth === 0) {
return source
.slice(start, i + 1)
.replace(/\s+/g, " ")
.trim();
}
}
throw new Error(`unbalanced braces after ${marker}`);
}

/** The `@scope` block for one data-bone-animate value. */
function scopeFor(value: string): string {
return blockAt(css, `@scope ([data-bone-animate="${value}"])`);
}

describe("bones.css reduced-motion contract", () => {
test("the default shimmer downgrades to pulse for every bone kind", () => {
const outside = css.split("@scope")[0];
const block = blockAt(outside, REDUCED);
for (const selector of [
'[data-bone="text"]::after',
'[data-bone="block"]',
'[data-bone="container"]::before',
]) {
expect(block).toContain(selector);
}
expect(block).toContain("animation: bone-pulse");
});

test("the shimmer and pulse scopes downgrade to pulse inside the scope", () => {
for (const value of ["shimmer", "pulse"]) {
const scope = scopeFor(value);
const block = blockAt(scope, REDUCED);
expect(block).toContain(":is(:scope, :scope *)");
expect(block).toContain("animation: bone-pulse");
}
});

test("the none scope has no reduced-motion override: none still means none", () => {
expect(scopeFor("none")).not.toContain(REDUCED);
});

test("nothing animates the aria-busy element itself", () => {
// The old reduced-motion override animated whatever carried aria-busy,
// which stacked an opacity pulse on top of the bar's own animation
// instead of replacing it. Bones target their bars, never the carrier.
expect(css).not.toContain('[aria-busy="true"]');
});
});
14 changes: 8 additions & 6 deletions packages/bones/tests/browser/adaptive-colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ function mount(html: string): HTMLElement {
const DARK_PAGE_TEXT = "color: rgb(238, 238, 238)";

test("text bone bar derives from the inherited text color", () => {
const root = mount(`<div style="${DARK_PAGE_TEXT}"><span data-bone="text">hidden</span></div>`);
// Marked bones shimmer by default (BON-16). The data-bone-animate="none"
// wrapper collapses the gradient to a solid background-color; without it
// the bar's paint lives in background-image.
const root = mount(
`<div data-bone-animate="none" style="${DARK_PAGE_TEXT}"><span data-bone="text">hidden</span></div>`,
);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expectColor(bar.backgroundColor, [238, 238, 238, 0.12]);
});

test("image block bone hides its alt text but keeps the inherited channels", () => {
const root = mount(
`<div style="color: rgb(51, 51, 51)"><img data-bone="block" alt="avatar" width="48" height="48" /></div>`,
`<div data-bone-animate="none" style="color: rgb(51, 51, 51)"><img data-bone="block" alt="avatar" width="48" height="48" /></div>`,
);
const img = getComputedStyle(root.querySelector("img")!);
// Alt text stays invisible…
Expand All @@ -40,9 +45,6 @@ test("image block bone hides its alt text but keeps the inherited channels", ()
});

test("auto.css text leaf bar derives from the inherited text color", () => {
// The data-bone-animate="none" wrapper collapses the shimmer gradient to a
// solid background-color; without it the bar's paint lives in
// background-image.
const root = mount(
`<div data-bone-animate="none"><section aria-busy="true" style="${DARK_PAGE_TEXT}"><p>some copy</p></section></div>`,
);
Expand All @@ -61,7 +63,7 @@ test("auto.css block bone derives from the inherited text color", () => {
test("default text color keeps today's black-at-12% bones", () => {
// Chromium's default color is black, so pages that never set a color get
// exactly the value the library shipped before the derivation change.
const root = mount(`<div><span data-bone="text">hidden</span></div>`);
const root = mount(`<div data-bone-animate="none"><span data-bone="text">hidden</span></div>`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expectColor(bar.backgroundColor, [0, 0, 0, 0.12]);
});
48 changes: 48 additions & 0 deletions packages/bones/tests/browser/default-animation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { afterEach, expect, test } from "vite-plus/test";
import "../../src/css/bones.css";

// ---------------------------------------------------------------------------
// BON-16: marked bones shimmer by default, the same as auto bones and the
// measured overlay. Before this, bones.css only animated inside a
// data-bone-animate scope, so a page mixing an aria-busy region with explicit
// data-bone markup showed shimmering auto bones beside frozen marked ones.
// data-bone-animate="none" is the opt-out.
// ---------------------------------------------------------------------------

afterEach(() => {
document.body.innerHTML = "";
});

function mount(html: string): HTMLElement {
document.body.insertAdjacentHTML("beforeend", html);
return document.body.lastElementChild as HTMLElement;
}

test("a text bone shimmers with no data-bone-animate anywhere", () => {
const root = mount(`<div><span data-bone="text">hidden</span></div>`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expect(bar.animationName).toBe("bone-shimmer");
});

test("a block bone shimmers with no data-bone-animate anywhere", () => {
const root = mount(`<div><img data-bone="block" alt="avatar" width="48" height="48" /></div>`);
expect(getComputedStyle(root.querySelector("img")!).animationName).toBe("bone-shimmer");
});

test("a container bone's cover shimmers with no data-bone-animate anywhere", () => {
const root = mount(`<div><div data-bone="container"><p>hidden</p></div></div>`);
const cover = getComputedStyle(root.querySelector("[data-bone]")!, "::before");
expect(cover.animationName).toBe("bone-shimmer");
});

test("data-bone-animate='none' on the bone itself opts out of the default", () => {
const root = mount(`<div><span data-bone="text" data-bone-animate="none">hidden</span></div>`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expect(bar.animationName).toBe("none");
});

test("data-bone-animate='none' on a wrapper opts its bones out of the default", () => {
const root = mount(`<div data-bone-animate="none"><span data-bone="text">hidden</span></div>`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expect(bar.animationName).toBe("none");
});
Loading