Getting started
Three steps: install the package, declare a theme, then wrap the area that is loading.
1. Install
Section titled “1. Install”pnpm add charpente# or: npm install charpente / yarn add charpente2. Declare the configuration
Section titled “2. Declare the configuration”A charpente.config.ts file at the project root. Everything is optional: without configuration, you get the solid theme and the shimmer motion.
import { defineCharpente } from 'charpente'
export default defineCharpente({ defaultTheme: 'solid', motion: 'shimmer', timing: { delay: 120 },})3. Wrap the area that is loading
Section titled “3. Wrap the area that is loading”Use the wrapper of your framework. It sets the attributes, announces the loading state to screen readers and handles the fade-in, so you never write data-charpente-* yourself.
| Framework | Package | Usage |
|---|---|---|
| Vue, Nuxt | charpente-vue |
<Charpente :loading="pending"> |
| React, Next | charpente-react |
<Charpente as="ul" loading={pending}> |
| Svelte | charpente-svelte |
<Charpente tag="main" loading={pending}> |
| Solid | charpente-solid |
<Charpente tag="main" loading={pending()}> |
| Preact | charpente-preact |
<Charpente as="ul" loading={pending}> |
| React Native | charpente-react-native |
<Charpente loading={pending}> with the package Text, Image, Pressable |
| Angular | charpente-angular |
<main [charpente]="pending"> |
| Astro | charpente-astro |
<Charpente as="main" loading={pending}> |
| Laravel, Livewire | charpente-blade |
<x-charpente :loading="$pending" tag="main"> |
| Vanilla | charpente/dom |
setLoading(element, { loading: true }) |
All of them take loading, theme, motion, delay, stagger and label. The client wrappers (Vue, React, Solid, Svelte, Angular) also take reveal and scope. Astro and Blade render on the server: they take reveal (the delay between levels) but the fade-in and the page scope need the client, through charpente/loader or setLoading. The wrapper renders the container itself (tag or as), so it adds no extra element when you point it at the root you already have.
When the data arrives, set loading to false: the real content is already there, in the same place, with no layout shift.
What the wrappers set
Section titled “What the wrappers set”In plain HTML, with no wrapper, set the same attributes by hand on the container:
data-charpente-loadingenables the skeleton on the whole subtree.aria-busy="true"tells screen readers that the area is being updated.inerttakes the area out of keyboard navigation and the accessibility tree, so nobody can click a fake button.
<section data-charpente-loading aria-busy="true" inert> <h2>Team</h2> <p>Four members</p> <img src="/photo.jpg" alt="Team photo" width="320" height="120" /> <button type="button">Invite</button></section>| Name | Where | Role |
|---|---|---|
data-charpente-loading |
container | Enables the skeleton. |
data-charpente-theme |
container | Picks a theme, otherwise defaultTheme. |
data-charpente-motion |
container | Names sorted alphabetically, separated by a space, or none. |
data-charpente-block |
an element | Paints the element as a single block and hides its children. |
data-charpente-ready |
container | Set for a few moments after loading for a fade-in. |
data-charpente-reveal |
container | Reveals nested levels one after the other once loading ends, see --charpente-reveal. |
--charpente-delay |
style | Delay before the skeleton appears, in ms. |
--charpente-stagger |
style | Offset between siblings, in ms. |
--charpente-reveal |
style | Delay between two nested levels when the content appears, in ms. |
Vite plugin and CLI
Section titled “Vite plugin and CLI”The Vite plugin compiles the configuration on the fly and exposes two virtual modules. It re-reads the file on every change.
import { charpente } from 'charpente/vite'
export default { plugins: [charpente()] }import 'virtual:charpente.css'virtual:charpente.theme gives today’s theme according to the seasonal calendar:
import { activeTheme } from 'virtual:charpente.theme'
document.documentElement.dataset.charpenteTheme = activeTheme()Without Vite, the charpente build command writes the CSS to a file. The charpente theme command prints the active theme for a date.
charpente build --config charpente.config.ts --out charpente.csscharpente theme --date 2026-10-31Integrations
Section titled “Integrations”Each snippet below comes from the repository’s examples/ folder. The examples drive the loading state with ?state=loading in the URL, for testing.
Plug in the plugin, import the CSS once in the entry point, then set the attributes.
import { charpente } from 'charpente/vite'
export default { plugins: [charpente()] }import 'virtual:charpente.css'import '../../../fixtures/page.css'import simple from '../../../fixtures/simple.html?raw'import complex from '../../../fixtures/complex.html?raw'
const pages = { simple, complex }const page = location.pathname.includes('complex') ? 'complex' : 'simple'const loading = new URLSearchParams(location.search).get('state') === 'loading'const html = pages[page]
document.getElementById('app').innerHTML = loading ? html.replace('data-testid="root"', 'data-testid="root" data-charpente-loading aria-busy="true" inert') : htmlNuxt and Vue
Section titled “Nuxt and Vue”Add the plugin to vite.plugins and the virtual CSS to css.
import { charpente } from 'charpente/vite'
export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devServer: { port: 5202 }, css: ['virtual:charpente.css'], vite: { plugins: [charpente()], server: { fs: { allow: ['../..'] } }, },})<script setup lang="ts">import { Charpente } from 'charpente-vue'import '../../../../fixtures/page.css'import html from '../../../../fixtures/simple.html?raw'
const route = useRoute()const loading = computed(() => route.query.state === 'loading')const inner = html.replace(/^<main[^>]*>/, '').replace(/<\/main>\s*$/, '')</script>
<template> <div> <Charpente tag="main" class="page" data-testid="root" :loading="loading" v-html="inner" /> </div></template>The charpente-vue package provides a <Charpente> component that sets the attributes, announces the loading state and handles the fade-out.
<script setup lang="ts">import { Charpente } from 'charpente-vue'</script>
<template> <Charpente :loading="pending" label="Loading the profile" :motion="['pulse', 'shimmer']" :delay="200"> <UserCard :user="user ?? placeholder" /> </Charpente></template>| Prop | Type | Default | Role |
|---|---|---|---|
loading |
boolean | false |
Enables the skeleton, sets aria-busy and inert. |
tag |
string | div |
Root element rendered. |
theme |
string | none | Local theme. |
motion |
string or string[] | none | Local motions, sorted automatically. |
delay |
number | none | Delay in ms. |
stagger |
number | none | Offset in ms. |
reveal |
number | none | Delay in ms between the levels of the content when loading ends: the first blocks appear, then what they contain one step later, and so on. |
scope |
'element' or 'page' |
config scope |
page puts the loading state on <html> so the whole page is covered, element keeps it on the wrapper. Applied once the component is mounted. |
label |
string | empty | Text of a role="status" region read during loading. |
revealMs |
number | 260 |
Duration of one fade-in. data-charpente-ready stays set for this duration plus five reveal steps. |
Same Vite plugin, then the wrapper in JSX.
import react from '@vitejs/plugin-react'import { charpente } from 'charpente/vite'import { defineConfig } from 'vite'
export default defineConfig({ server: { port: 5203, strictPort: true, fs: { allow: ['../..'] } }, plugins: [react(), charpente()], build: { target: "esnext" },})import { Charpente } from 'charpente-react'
export const Products = ({ items, pending }: Props) => ( <Charpente as="ul" className="grid" loading={pending} label="Loading products"> {items.map((item) => <ProductTile key={item.id} product={item} />)} </Charpente>)Next does not use Vite: the CLI writes the CSS before startup and build, then the layout imports it.
{ "scripts": { "charpente": "charpente build --out app/charpente.css", "predev": "pnpm run charpente", "dev": "next dev -p 5204", "prebuild": "pnpm run charpente", "build": "next build", "start": "next start -p 5204" }}import { defineCharpente } from 'charpente'
export default defineCharpente({ defaultTheme: 'solid', motion: 'shimmer', timing: { delay: 120, stagger: 40 },})import type { ReactNode } from "react"import "./charpente.css"import "./page.css"
export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="fr"> <body>{children}</body> </html> )}export type SearchParams = Promise<{ state?: string | string[] }>
export const isLoading = async (searchParams: SearchParams): Promise<boolean> => { const { state } = await searchParams const value = Array.isArray(state) ? state[0] : state return value === "loading"}SvelteKit
Section titled “SvelteKit”import adapter from "@sveltejs/adapter-auto"import { sveltekit } from "@sveltejs/kit/vite"import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"import { charpente } from "charpente/vite"import { defineConfig } from "vite"
export default defineConfig({ server: { port: 5205, strictPort: true }, plugins: [ charpente(), sveltekit({ preprocess: vitePreprocess(), adapter: adapter() }), ],})<script lang="ts"> import "virtual:charpente.css" import "../page.css"
let { children } = $props()</script>
{@render children()}import type { PageServerLoad } from "./$types"
export const load: PageServerLoad = ({ url }) => ({ loading: url.searchParams.get("state") === "loading",})<Charpente tag="main" data-testid="root" class="page" loading={data.loading}>This site is itself built this way. The repository’s example renders the page on the server, this site generates it statically: the plugin is identical.
import { defineConfig } from "astro/config"import node from "@astrojs/node"import { charpente } from "charpente/vite"
export default defineConfig({ output: "server", adapter: node({ mode: "standalone" }), server: { port: 5206, host: true }, vite: { plugins: [charpente()] },})---import "virtual:charpente.css"import "../styles/page.css"---<!doctype html><html lang="fr"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>Charpente Astro</title> </head> <body> <slot /> </body></html>---import Charpente from "charpente-astro"import Layout from "../layouts/Layout.astro"export const prerender = falseconst loading = Astro.url.searchParams.get("state") === "loading"---<Layout><Charpente as="main" loading={loading} data-testid="root" class="page">Livewire and Laravel
Section titled “Livewire and Laravel”The Vite plugin sits next to laravel-vite-plugin. The loading state comes from a component property, the Blade template uses the <x-charpente> component from charpente-blade.
import { defineConfig } from 'vite'import laravel from 'laravel-vite-plugin'import { charpente } from 'charpente/vite'
export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, }), charpente(), ],})import 'virtual:charpente.css'<?php
namespace App\Livewire;
use Livewire\Attributes\Layout;use Livewire\Component;
#[Layout('components.layouts.app')]class Simple extends Component{ public bool $loading = false;
public function mount(): void { $this->loading = request()->query('state') === 'loading'; }
public function render() { return view('livewire.simple'); }}<x-charpente :loading="$loading" tag="main" data-testid="root" class="page">Solid, Angular and vanilla
Section titled “Solid, Angular and vanilla”import { Charpente } from 'charpente-solid'
<Charpente tag="main" loading={pending()}>{children}</Charpente><main [charpente]="pending">...</main>import { setLoading } from 'charpente/dom'
setLoading(document.querySelector('main')!, { loading: true, theme: 'neon' })Plain HTML
Section titled “Plain HTML”Without a bundler, generate the file with the CLI and link it. Here is the markup of the example page fixtures/simple.html.
charpente build --out public/charpente.css<link rel="stylesheet" href="/charpente.css" />Then use the <charpente-loader> element, registered by charpente/loader. It plays the role of the wrappers: add the loading attribute while the area loads and remove it when the content is ready.
<script type="module"> import 'charpente/loader'</script>
<charpente-loader loading theme="night" motion="pulse" stagger="60" reveal="800"> <h2>Team</h2> <p>A short introduction to the team.</p></charpente-loader>| Attribute | Role |
|---|---|
loading |
Enables the skeleton. Also available as the loading property. |
theme, motion |
Local theme and motions, names separated by a space. |
delay, stagger, reveal |
Timing in ms, like the props of the wrappers. |
reveal-ms |
Duration of one fade-in, 260 by default. |
scope |
page puts the loading state on <html>, element keeps it on the loader. Defaults to the scope of the configuration. |
The element sets aria-busy and inert for you and cleans up when it leaves the page. It has no label attribute: describe the loading state with your own role="status" region.
<main data-testid="root" class="page"> <header class="page__head"> <h1>Équipe</h1> <p>Quatre collaborateurs</p> </header> <ul class="people"> <li class="person"> <img class="person__avatar" width="48" height="48" alt="" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" /> <div class="person__body"> <h2>Camille Durand</h2> <p>Cheffe de projet</p> </div> ...