Getting started
If you arrived here with a toast still on screen: that’s Crust carrying its toaster across an Astro view transition. Nothing re-mounted, no timer reset.
Crust is a toast library with one store and one renderer. The renderer is
vanilla DOM, so it runs anywhere a <script> runs. A React-free Astro site
is a first-class citizen, not a fallback. React support is a thin component
over the same machinery.
Install
pnpm add @oscarrc/crust
Crust has zero hard dependencies. React is an optional peer; install it only if you use the React entry point.
Include the stylesheet once, wherever your global styles live:
import '@oscarrc/crust/styles.css';
1. Pure Astro, no React anywhere
Mount the toaster once in your layout, then trigger toasts from any script on any page:
---
// src/layouts/Layout.astro
import '@oscarrc/crust/styles.css';
---
<html>
<body>
<slot />
<script>
import { mountToaster } from '@oscarrc/crust/vanilla';
mountToaster();
</script>
</body>
</html>
<button data-save>Save</button>
<script>
import { toast } from '@oscarrc/crust/vanilla';
// Delegate from `document`: with Astro view transitions, listeners
// bound to specific elements die when the <body> is swapped.
document.addEventListener('click', (event) => {
if (!(event.target as HTMLElement).closest('[data-save]')) return;
toast.success('Saved', { message: 'Fresh bread out of the oven.' });
});
</script>
mountToaster() is idempotent. Calling it twice returns the same handle, so
you never end up with two toasters.
2. Astro with React islands
Use the <Toaster /> component instead of mountToaster(). With Astro view
transitions (<ClientRouter />), add transition:persist so the island,
including live toasts and their timers, survives page navigation:
---
import { ClientRouter } from 'astro:transitions';
import { Toaster } from '@oscarrc/crust/react';
import '@oscarrc/crust/styles.css';
---
<html>
<head><ClientRouter /></head>
<body>
<slot />
<Toaster client:load transition:persist />
</body>
</html>
Triggers work identically from islands and plain scripts. It’s one shared store:
import { toast } from '@oscarrc/crust/vanilla';
import { useToasts } from '@oscarrc/crust/react';
export function Dashboard() {
const active = useToasts();
return (
<button onClick={() => toast.info('From inside an island.')}>
Active toasts: {active.length}
</button>
);
}
The badge on the landing page works exactly like this: vanilla buttons and a React island feeding one store.
3. Plain React apps
Crust isn’t Astro-exclusive. Mount <Toaster /> once near your app root and
call toast() from anywhere. No provider, no context:
import { Toaster } from '@oscarrc/crust/react';
import '@oscarrc/crust/styles.css';
export function App() {
return (
<>
<Routes />
<Toaster />
</>
);
}
Keep the <Toaster /> at the root, outside your route outlet. Client-side
route changes then never touch it, and toasts ride along across navigations
for free.
Your first toast
import { toast } from '@oscarrc/crust/vanilla';
toast('Plain and simple.');
toast.success('Saved', { message: 'It worked.' });
toast.error('Burnt', { message: 'It did not.', duration: 8000 });
Give a toast a message and it gains Crust’s signature move: the compact
capsule morphs into a card on hover, focus or tap: one continuous surface,
message revealed. Head to the API for everything else, or the
playground to try every option live.