+{/* Pulse one section */}
+
-{/* Animate the entire app */}
-
+{/* Keep every skeleton in the app still */}
+
```
Speed is controlled by `--bone-duration`. Shimmer colors use `--bone-base` and `--bone-highlight`.
@@ -78,5 +79,5 @@ Speed is controlled by `--bone-duration`. Shimmer colors use `--bone-base` and `
## 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`.
diff --git a/packages/bones/README.md b/packages/bones/README.md
index c082b47..84450bd 100644
--- a/packages/bones/README.md
+++ b/packages/bones/README.md
@@ -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
diff --git a/packages/bones/src/css/auto.css b/packages/bones/src/css/auto.css
index 5e3fac4..dab0ef9 100644
--- a/packages/bones/src/css/auto.css
+++ b/packages/bones/src/css/auto.css
@@ -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"]
diff --git a/packages/bones/src/css/bones.css b/packages/bones/src/css/bones.css
index 1e86b0e..5ac211d 100644
--- a/packages/bones/src/css/bones.css
+++ b/packages/bones/src/css/bones.css
@@ -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;
@@ -128,22 +156,38 @@ 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;
@@ -151,9 +195,3 @@ video[data-bone="block"] {
background-size: auto;
}
}
-
-@media (prefers-reduced-motion: reduce) {
- [aria-busy="true"] {
- animation: bone-pulse 2s ease-in-out infinite;
- }
-}
diff --git a/packages/bones/tests/bones-css.test.ts b/packages/bones/tests/bones-css.test.ts
new file mode 100644
index 0000000..f03799e
--- /dev/null
+++ b/packages/bones/tests/bones-css.test.ts
@@ -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"]');
+ });
+});
diff --git a/packages/bones/tests/browser/adaptive-colors.test.ts b/packages/bones/tests/browser/adaptive-colors.test.ts
index c2814a0..004c38d 100644
--- a/packages/bones/tests/browser/adaptive-colors.test.ts
+++ b/packages/bones/tests/browser/adaptive-colors.test.ts
@@ -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(`
hidden
`);
+ // 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(
+ `
hidden
`,
+ );
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(
- `
`,
+ `
`,
);
const img = getComputedStyle(root.querySelector("img")!);
// Alt text stays invisible…
@@ -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(
`
`,
);
@@ -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(`
hidden
`);
+ const root = mount(`
hidden
`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expectColor(bar.backgroundColor, [0, 0, 0, 0.12]);
});
diff --git a/packages/bones/tests/browser/default-animation.test.ts b/packages/bones/tests/browser/default-animation.test.ts
new file mode 100644
index 0000000..f46c772
--- /dev/null
+++ b/packages/bones/tests/browser/default-animation.test.ts
@@ -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(`
hidden
`);
+ 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(`
`);
+ 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(`
`);
+ 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(`
hidden
`);
+ 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(`
hidden
`);
+ const bar = getComputedStyle(root.querySelector("span")!, "::after");
+ expect(bar.animationName).toBe("none");
+});