Skip to content

Getting started

Three steps: install the package, declare a theme, then wrap the area that is loading.

Terminal window
pnpm add charpente
# or: npm install charpente / yarn add charpente

A charpente.config.ts file at the project root. Everything is optional: without configuration, you get the solid theme and the shimmer motion.

charpente.config.ts
import { defineCharpente } from 'charpente'
export default defineCharpente({
defaultTheme: 'solid',
motion: 'shimmer',
timing: { delay: 120 },
})

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.

In plain HTML, with no wrapper, set the same attributes by hand on the container:

  • data-charpente-loading enables the skeleton on the whole subtree.
  • aria-busy="true" tells screen readers that the area is being updated.
  • inert takes 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.

The Vite plugin compiles the configuration on the fly and exposes two virtual modules. It re-reads the file on every change.

vite.config.ts
import { charpente } from 'charpente/vite'
export default { plugins: [charpente()] }
main.ts
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.

Terminal window
charpente build --config charpente.config.ts --out charpente.css
charpente theme --date 2026-10-31

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.

vite.config.ts
import { charpente } from 'charpente/vite'
export default { plugins: [charpente()] }
examples/vanilla/src/fixture.js
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')
: html

Add the plugin to vite.plugins and the virtual CSS to css.

examples/nuxt/nuxt.config.ts
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: ['../..'] } },
},
})
examples/nuxt/app/pages/simple.vue
<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.

examples/react/vite.config.ts
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.

examples/next/package.json
{
"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"
}
}
examples/next/charpente.config.ts
import { defineCharpente } from 'charpente'
export default defineCharpente({
defaultTheme: 'solid',
motion: 'shimmer',
timing: { delay: 120, stagger: 40 },
})
examples/next/app/layout.tsx
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>
)
}
examples/next/app/_lib/state.ts
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"
}
examples/svelte/vite.config.ts
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() }),
],
})
examples/svelte/src/routes/+layout.svelte
<script lang="ts">
import "virtual:charpente.css"
import "../page.css"
let { children } = $props()
</script>
{@render children()}
examples/svelte/src/routes/simple/+page.server.ts
import type { PageServerLoad } from "./$types"
export const load: PageServerLoad = ({ url }) => ({
loading: url.searchParams.get("state") === "loading",
})
examples/svelte/src/routes/simple/+page.svelte
<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.

examples/astro/astro.config.mjs
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()] },
})
examples/astro/src/layouts/Layout.astro
---
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>
examples/astro/src/pages/simple.astro
---
import Charpente from "charpente-astro"
import Layout from "../layouts/Layout.astro"
export const prerender = false
const loading = Astro.url.searchParams.get("state") === "loading"
---
<Layout>
<Charpente as="main" loading={loading} data-testid="root" class="page">

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.

examples/livewire/vite.config.js
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(),
],
})
examples/livewire/resources/js/app.js
import 'virtual:charpente.css'
examples/livewire/app/Livewire/Simple.php
<?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');
}
}
examples/livewire/resources/views/livewire/simple.blade.php
<x-charpente :loading="$loading" tag="main" data-testid="root" class="page">
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' })

Without a bundler, generate the file with the CLI and link it. Here is the markup of the example page fixtures/simple.html.

Terminal window
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.

fixtures/simple.html
<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>
...