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/animate-on-carrier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@camp.dev/bones": patch
---

`data-bone-animate` now takes effect from the element that carries it: a marked bone can animate itself, and an `aria-busy` region can be stilled or switched from its own tag instead of a wrapper. The animation variants live in `@scope` blocks, and a scoped selector's implicit `:scope` prefix matches strict descendants only, so the attribute was a silent no-op on the scope root itself. The scoped selectors now include the root via `:is(:scope, :scope *)`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,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` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. 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`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.

## Without React

Expand Down
2 changes: 1 addition & 1 deletion 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 a parent element to animate them.
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.

### Shimmer

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/styling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ One case needs a manual override: text that depends on its background for contra

## Animations

Add `data-bone-animate` to any parent element to animate skeletons inside it:
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.

- `shimmer` - horizontal highlight sweep
- `pulse` - gentle opacity fade (also kicks in automatically for `prefers-reduced-motion: reduce`)
Expand Down
2 changes: 1 addition & 1 deletion packages/bones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,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` also has to sit on an ancestor of the `aria-busy` element — set directly on it, it has no effect. 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`, auto bones always shimmer, and `data-bone-animate="pulse"` and `"none"` cannot change that. The `prefers-reduced-motion` fallback to pulse still applies.

## Without React

Expand Down
46 changes: 24 additions & 22 deletions packages/bones/src/css/auto.css
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,9 @@
/* 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. */
that do set it. 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"]
:not(:has(*)):not(
[data-bone],
Expand Down Expand Up @@ -374,7 +376,7 @@
}

@scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -403,7 +405,7 @@
select *,
object *
)::before,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -432,7 +434,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand Down Expand Up @@ -461,7 +463,7 @@
}

@media (prefers-reduced-motion: reduce) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -490,7 +492,7 @@
select *,
object *
)::before,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -519,7 +521,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand All @@ -544,7 +546,7 @@
}

@media (forced-colors: active) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -573,7 +575,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand All @@ -598,7 +600,7 @@
}

@scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -627,7 +629,7 @@
select *,
object *
)::before,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -656,7 +658,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand All @@ -680,7 +682,7 @@
}

@media (prefers-reduced-motion: reduce) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -709,7 +711,7 @@
select *,
object *
)::before,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -738,7 +740,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand All @@ -763,7 +765,7 @@
}

@media (forced-colors: active) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -792,7 +794,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand All @@ -817,7 +819,7 @@
}

@scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -846,7 +848,7 @@
select *,
object *
)::before,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -875,7 +877,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand All @@ -899,7 +901,7 @@
}

@media (forced-colors: active) {
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:not(:has(*)):not(
[data-bone],
[data-bone] *,
Expand Down Expand Up @@ -928,7 +930,7 @@
select *,
object *
)::after,
[aria-busy="true"]
[aria-busy="true"]:is(:scope, :scope *)
:is(
img,
svg,
Expand Down
28 changes: 16 additions & 12 deletions packages/bones/src/css/bones.css
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,15 @@ video[data-bone="block"] {
}
}

/* :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). */
@scope ([data-bone-animate="shimmer"]) to ([data-bone-animate]:not([data-bone-animate="shimmer"])) {
[data-bone="text"]::after,
[data-bone="text"]::before,
[data-bone="block"],
[data-bone="container"]::before {
[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;
background: linear-gradient(
90deg,
Expand All @@ -127,21 +131,21 @@ video[data-bone="block"] {
}

@scope ([data-bone-animate="pulse"]) to ([data-bone-animate]:not([data-bone-animate="pulse"])) {
[data-bone="text"]::after,
[data-bone="text"]::before,
[data-bone="block"],
[data-bone="container"]::before {
[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;
}
}

@scope ([data-bone-animate="none"]) to ([data-bone-animate]:not([data-bone-animate="none"])) {
[data-bone="text"]::after,
[data-bone="text"]::before,
[data-bone="block"],
[data-bone="container"]::before {
[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;
Expand Down
3 changes: 1 addition & 2 deletions packages/bones/tests/browser/adaptive-colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ 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. The attribute must wrap the busy region — its @scope
// does not take effect from the aria-busy element itself.
// 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 Down
90 changes: 90 additions & 0 deletions packages/bones/tests/browser/animation-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { afterEach, expect, test } from "vite-plus/test";
import "../../src/css/auto.css";

// ---------------------------------------------------------------------------
// BON-17: data-bone-animate must take effect from the element that carries it,
// not only from a wrapper. The animation variants live in @scope blocks, and a
// scoped selector's implicit :scope prefix only matches strict descendants —
// so the attribute was a silent no-op on the marked bone itself and on the
// aria-busy element of an auto region.
// ---------------------------------------------------------------------------

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

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

// --- marked bones (bones.css, pulled in via auto.css's @import) -------------

test("data-bone-animate on the marked bone itself takes effect", () => {
const root = mount(`<div><span data-bone="text" data-bone-animate="shimmer">hidden</span></div>`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expect(bar.animationName).toBe("bone-shimmer");
});

test("data-bone-animate on a wrapper still animates marked bones", () => {
const root = mount(`<div data-bone-animate="pulse"><span data-bone="text">hidden</span></div>`);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expect(bar.animationName).toBe("bone-pulse");
});

test("a marked bone opts out of a wrapper's animation with its own attribute", () => {
const root = mount(
`<div data-bone-animate="shimmer"><span data-bone="text" data-bone-animate="none">hidden</span></div>`,
);
const bar = getComputedStyle(root.querySelector("span")!, "::after");
expect(bar.animationName).toBe("none");
});

test("a block bone carrying the attribute animates itself", () => {
const root = mount(
`<div><img data-bone="block" data-bone-animate="pulse" alt="avatar" width="48" height="48" /></div>`,
);
const img = getComputedStyle(root.querySelector("img")!);
expect(img.animationName).toBe("bone-pulse");
});

// --- auto bones (aria-busy regions) -----------------------------------------

test("data-bone-animate='none' on the aria-busy element stops the default shimmer", () => {
const root = mount(
`<section aria-busy="true" data-bone-animate="none"><p>some copy</p></section>`,
);
const bar = getComputedStyle(root.querySelector("p")!, "::after");
expect(bar.animationName).toBe("none");
});

test("data-bone-animate='pulse' on the aria-busy element switches its leaves", () => {
const root = mount(
`<section aria-busy="true" data-bone-animate="pulse"><p>some copy</p></section>`,
);
const bar = getComputedStyle(root.querySelector("p")!, "::after");
expect(bar.animationName).toBe("bone-pulse");
});

test("data-bone-animate='none' on the aria-busy element also stills replaced elements", () => {
const root = mount(
`<section aria-busy="true" data-bone-animate="none"><img alt="avatar" width="48" height="48" /></section>`,
);
expect(getComputedStyle(root.querySelector("img")!).animationName).toBe("none");
});

test("data-bone-animate on a wrapper still governs an auto region", () => {
const root = mount(
`<div data-bone-animate="none"><section aria-busy="true"><p>some copy</p></section></div>`,
);
const bar = getComputedStyle(root.querySelector("p")!, "::after");
expect(bar.animationName).toBe("none");
});

test("an inner value on the aria-busy element beats an outer wrapper", () => {
const root = mount(
`<div data-bone-animate="shimmer"><section aria-busy="true" data-bone-animate="none"><p>some copy</p></section></div>`,
);
const bar = getComputedStyle(root.querySelector("p")!, "::after");
expect(bar.animationName).toBe("none");
});
Loading