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

Add `@camp.dev/bones/element`, a `<bones-boundary>` custom element that owns the loading state of its subtree. Set `busy` and the element sets `aria-busy="true"` and `inert` after a `delay` (200 ms), keeps them for at least `min-duration` (400 ms), and removes them inside a view transition where the browser supports one. It fires `bones:show` and `bones:hide`, honors `force` for demos, and adopts server-rendered `aria-busy` on upgrade. `@camp.dev/bones/react` gains a hook-free `<BonesBoundary>` wrapper.
32 changes: 26 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ import "@camp.dev/bones/css";

## Entry points

| Import | Contents |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
| Import | Contents |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones/element` | `<bones-boundary>`, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |

React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.

Expand Down Expand Up @@ -129,6 +130,25 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b

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.

## Without React

`<bones-boundary>` manages the loading state for any stack. Set `busy` when a request starts and clear it when the response lands. The element waits 200 ms before showing bones and keeps them for at least 400 ms, then crossfades to content with the View Transitions API where available.

```html
<script type="module">
import "@camp.dev/bones/element";
</script>

<bones-boundary busy>
<h2>Title</h2>
<p>Body copy.</p>
</bones-boundary>
```

`@camp.dev/bones/element` is a bare specifier. A browser cannot resolve it on its own, so this snippet needs a bundler or an import map. To load the element straight from a CDN in a plain HTML file, see the URL form on the [bones-boundary docs page](apps/docs/content/docs/api/bones-boundary.mdx).

Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `<BonesBoundary>` from `@camp.dev/bones/react`.

## Development

```bash
Expand Down
130 changes: 130 additions & 0 deletions apps/docs/content/docs/api/bones-boundary.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
title: bones-boundary
description: A custom element that manages the loading state of its subtree.
---

```ts
import "@camp.dev/bones/element";
```

`<bones-boundary>` sets `aria-busy="true"` and `inert` on itself while its subtree is loading, and removes them when it is not. It draws nothing. `@camp.dev/bones/css` styles `data-bone` markup and `@camp.dev/bones/auto.css` styles unmarked leaves, and both key on `aria-busy="true"`.

Set `busy` when loading starts and remove it when loading ends. The element waits `delay` before it shows bones, so a fast response never flashes a skeleton, and it keeps bones for at least `min-duration`, so a slow one never strobes.

## Usage

```html
<link rel="stylesheet" href="https://unpkg.com/@camp.dev/bones/src/css/auto.css" />
<script type="module">
import "https://unpkg.com/@camp.dev/bones/dist/element/index.mjs";
</script>

<bones-boundary id="profile" busy>
<h2>Loading profile</h2>
<p>Three lines of copy become three bones while busy.</p>
</bones-boundary>

<script type="module">
await customElements.whenDefined("bones-boundary");
const boundary = document.getElementById("profile");
const response = await fetch("/api/profile");
const profile = await response.json();
boundary.querySelector("h2").textContent = profile.name;
boundary.querySelector("p").textContent = profile.bio;
boundary.busy = false;
</script>
```

The second script waits for the definition instead of trusting script order. A classic `<script>` in its place would run before the deferred module import above it and touch a boundary that has not upgraded yet. A property written before the upgrade is replayed when the upgrade happens, but a property read before it returns `undefined`.

## Attributes

| Attribute | Property | Type | Default | Description |
| -------------- | ------------- | -------------------- | -------- | ------------------------------------------------------------------ |
| `busy` | `busy` | boolean | absent | The subtree is loading. |
| `force` | `force` | boolean | absent | Show bones now and keep them until removed. Skips both timers. |
| `delay` | `delay` | number (ms) | `200` | How long `busy` must stay set before bones show. |
| `min-duration` | `minDuration` | number (ms) | `400` | Once shown, bones stay at least this long. |
| `transition` | `transition` | `"auto"` or `"none"` | `"auto"` | Whether hiding bones runs inside `document.startViewTransition()`. |

Every property reflects to its attribute. A `delay` or `min-duration` that is missing, negative, or not a number falls back to the default. The read-only `showing` property is `true` while bones are visible.

A new `delay` or `min-duration` applies to the next transition, not the one already running. The element reads each value once, at the moment it starts the timer.

## Output attributes

While bones show, the element carries `aria-busy="true"` and `inert`. `inert` removes the subtree from the tab order and from assistive technology, and ignores pointer events. Do not set these two attributes yourself on the client. The element puts them back if anything else removes them while bones are showing. Setting them on the server is how you render the loading state before JavaScript runs (see below).

`inert` blurs whatever was focused inside the boundary, and the element does not restore that focus when bones hide. Move focus yourself in a `bones:hide` listener if the interaction needs it.

An element that upgrades with `aria-busy="true"` already in its markup adopts the showing state, so `bones:show` fires on upgrade as well as after `delay`.

## Events

| Event | When |
| ------------ | --------------------------------------------------------------- |
| `bones:show` | After `aria-busy` and `inert` are set. |
| `bones:hide` | After they are removed. A view transition may still be playing. |

Both bubble and cross shadow roots. Neither is cancelable.

```js
boundary.addEventListener("bones:hide", () => console.log("content visible"));
```

## The swap

When `busy` is removed and `min-duration` has elapsed, the element removes its output attributes inside `document.startViewTransition()`, which crossfades bones into content. The call is skipped when `transition="none"` is set, when the browser has no `startViewTransition`, or when `prefers-reduced-motion: reduce` matches. In every case the content appears.

Two limits of the View Transitions API apply:

- The API snapshots the whole document. If two boundaries hide within the same crossfade, the browser skips the first transition and runs the second.
- The page does not respond to input for the length of the crossfade. Set `transition="none"` on boundaries where that matters.

## Server rendering

Render the output attributes yourself when you know a region is loading at request time:

```html
<bones-boundary busy aria-busy="true" inert>…</bones-boundary>
```

The stylesheet paints bones and the subtree is inert before the element upgrades. On upgrade the element adopts the showing state, and `min-duration` counts from that moment.

## React

`@camp.dev/bones/react` exports a typed wrapper. Import the element once in a client entry so the tag is registered.

```tsx
import "@camp.dev/bones/element";
import { BonesBoundary } from "@camp.dev/bones/react";

function Profile({ loading, user }: { loading: boolean; user?: User }) {
return (
<BonesBoundary busy={loading} minDuration={600} onHide={() => track("profile:visible")}>
<h2>{user?.name ?? "Loading"}</h2>
<p>{user?.bio ?? "Three lines of copy become three bones while busy."}</p>
</BonesBoundary>
);
}
```

| Prop | Maps to | Notes |
| ------------- | -------------- | ------------------------------------------------------------------------------------ |
| `busy` | `busy` | |
| `force` | `force` | Also renders `aria-busy="true"` and `inert` on the server. |
| `delay` | `delay` | |
| `minDuration` | `min-duration` | |
| `transition` | `transition` | |
| `onShow` | `bones:show` | Listener. Passing a function from a server component is an error, as with `onClick`. |
| `onHide` | `bones:hide` | Same. |

The wrapper uses no hooks, so it renders inside server components. `<Bones>` and Suspense do not need it: the Suspense fallback renders `data-bone` markup that the stylesheet styles on its own.

The wrapper normally leaves the output attributes to the element. When `force` is true, it renders `aria-busy` and `inert` so the server paints bones, and React then owns those two attributes and removes them on a later update. The element writes them back while bones are showing. The wrapper also sets `suppressHydrationWarning`, so a boundary that already shows bones by the time React hydrates it is not reported as a mismatch.

The package augments `JSX.IntrinsicElements` with `"bones-boundary"`. A project that already declares the same tag gets a conflicting-declaration error from TypeScript. Delete the local declaration and use this one.

## Browser support

Custom elements, `inert` (Chrome 102, Safari 15.5, Firefox 112), and the `:has()` selector that `auto.css` already needs. View Transitions are detected at runtime.
2 changes: 1 addition & 1 deletion apps/docs/content/docs/api/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "API Reference",
"pages": ["create-bones", "bones-component", "bones-force", "force-bones"]
"pages": ["create-bones", "bones-component", "bones-force", "force-bones", "bones-boundary"]
}
32 changes: 26 additions & 6 deletions packages/bones/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ import "@camp.dev/bones/css";

## Entry points

| Import | Contents |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |
| Import | Contents |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `@camp.dev/bones/react` | `createBones`, `readPromise`, `forceBones`, `minMax`, `<Bones>`, `<BonesForce>` |
| `@camp.dev/bones/css` | The skeleton stylesheet. Import once in your root layout. |
| `@camp.dev/bones/auto.css` | Skeletonizes unmarked leaves under `aria-busy="true"`. Imports the base stylesheet itself, so a separate `/css` import is optional. |
| `@camp.dev/bones/element` | `<bones-boundary>`, a custom element that sets `aria-busy` and `inert` on its subtree with `delay`, `min-duration`, and a crossfade. |
| `@camp.dev/bones` | The framework-agnostic core (`boneAttributes`, `minMax`). You only need this to build your own renderer or adapter. |

React is an optional peer dependency: installing the package without React is supported and only the `/react` entry requires it.

Expand Down Expand Up @@ -129,6 +130,25 @@ Set `aria-busy="true"` on the loading region and every unmarked leaf inside it b

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.

## Without React

`<bones-boundary>` manages the loading state for any stack. Set `busy` when a request starts and clear it when the response lands. The element waits 200 ms before showing bones and keeps them for at least 400 ms, then crossfades to content with the View Transitions API where available.

```html
<script type="module">
import "@camp.dev/bones/element";
</script>

<bones-boundary busy>
<h2>Title</h2>
<p>Body copy.</p>
</bones-boundary>
```

`@camp.dev/bones/element` is a bare specifier. A browser cannot resolve it on its own, so this snippet needs a bundler or an import map. To load the element straight from a CDN in a plain HTML file, see the URL form on the [bones-boundary docs page](https://github.com/campdotdev/bones/blob/main/apps/docs/content/docs/api/bones-boundary.mdx).

Pair it with `auto.css` for zero-markup skeletons, or with `data-bone` markup from `boneAttributes`. The element is also exported for React as `<BonesBoundary>` from `@camp.dev/bones/react`.

## Development

```bash
Expand Down
4 changes: 3 additions & 1 deletion packages/bones/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@
],
"type": "module",
"sideEffects": [
"*.css"
"*.css",
"./dist/element/index.mjs"
],
"exports": {
".": "./dist/index.mjs",
"./element": "./dist/element/index.mjs",
"./react": "./dist/react/index.mjs",
"./package.json": "./package.json",
"./css": {
Expand Down
Loading
Loading