Don't throw the crust. An opinionated, crisp, and zero-waste toast library built natively for Astro and React.
Most toast libraries are bloated white bread. They force you into wrapper fatigue, endless configuration files, and a hard React dependency just to slide a notification onto the screen.
Crust is different. The renderer is vanilla DOM — a React-free Astro site is a first-class citizen, and React gets a thin, concurrent-safe bridge on top. Warm matte surfaces, a capsule that grows into a card as one continuous surface, and rock-solid defaults. Take it or leave it — just like the crust.
- 🚀 Astro-first, honestly. The core renders with plain DOM. No React island required to show toasts — React is an optional peer dependency.
- 🫙 One shared store. Trigger from an Astro
<script>, a React island, or anywhere else: same toaster, same stack, no provider, no context. - 🔥 Crisp & opinionated. A tactile morph-expand interaction and an organic motion profile (ease-out-quint, nothing over 320ms, zero bounce). No spending 40 minutes tweaking cubic-béziers.
- ♿ Accessible by default.
aria-liveregion, keyboard-reachable dismiss, timers that pause while you read, andprefers-reduced-motionas a first-class theme. - 📦 Zero-waste footprint. ESM-only, ~2 KB of JS, one CSS file, no dependencies.
Install the package:
pnpm add @oscarrc/crustImport the styles and mount the toaster once, at the root of your layout — no React, no provider:
import '@oscarrc/crust/styles.css';
import { mountToaster } from '@oscarrc/crust/vanilla';
mountToaster(); // bottom-right by defaultThen bake toasts from anywhere:
import { toast } from '@oscarrc/crust/vanilla';
toast.success('Fresh bread out of the oven!');
// A message makes a toast expandable — it morphs open on hover/focus/tap,
// or on its own with `expandAfter`:
toast('Order update', {
message: 'Your order shipped today.',
expandAfter: 2000 // auto-expands 2s after becoming visible
});
// Async flows: loading → success/error, opening the outcome by itself:
toast.promise(saveDraft(), {
loading: 'Saving…',
success: (draft) => ({ title: 'Saved', message: `“${draft.name}” is safe.` }),
error: 'Save failed'
}, { expandOnSettle: true });That's the whole setup. For React islands, view transitions, theming, and the rest, read on — or head to the docs & playground.
---
// src/layouts/Layout.astro
import '@oscarrc/crust/styles.css';
---
<html>
<body>
<slot />
<script>
import { mountToaster } from '@oscarrc/crust/vanilla';
mountToaster();
</script>
</body>
</html><button id="alert">Bake toast</button>
<script>
import { toast } from '@oscarrc/crust/vanilla';
document.getElementById('alert')?.addEventListener('click', () => {
toast.success('Bakery live', { message: 'Fresh bread out of the oven!' });
});
</script>Mount <Toaster /> once in your shell layout. With view transitions
(<ClientRouter />), transition:persist carries live toasts across page
navigations:
---
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>import { toast } from '@oscarrc/crust/vanilla';
import { useToasts } from '@oscarrc/crust/react';
export function Dashboard() {
const active = useToasts();
return (
<button onClick={() => toast.info('Triggered inside an island!')}>
Active toasts ({active.length})
</button>
);
}toast('title', { message, type, duration, icon });
toast.success('…'); toast.error('…'); toast.info('…'); toast.warning('…');
const id = toast.loading('Uploading…'); // persistent spinner
toast.update(id, { title: 'Done', type: 'success', duration: 4000 });
toast.promise(save(), { // loading → success/error
loading: 'Saving…',
success: (v) => `Saved ${v.name}`,
error: 'Save failed'
});
toast('Hi', { message: 'Read me', expandAfter: 2000 }); // opens itself after 2s
// toast.promise(…, { expandOnSettle: true }) opens the outcome
toast.dismiss(id); // one
toast.dismiss(); // all, queue included
mountToaster({ position: 'bottom-right', maxVisible: 5, icons: { … } });durationdefaults to 4000ms;Infinity(or0) means persistent.- A toast with a
messagemorphs open on hover/focus/tap to reveal it; the timer pauses while you read. - Icons accept an SVG string, an
Element, or a factory —lucideandlucide-staticwork out of the box (lucide-reactdoesn't; the renderer isn't React). - Theme everything via
--crust-*custom properties — see the theming docs.
Deliberately not in scope: JSX toast content — the renderer is vanilla DOM, which is exactly what makes the React-free story work. Opinionated means opinionated.
pnpm install # link workspaces
pnpm build:lib # build the package → packages/crust/dist
pnpm test # vitest: store, renderer, react bridge
pnpm dev # tsup --watch + astro dev (docs playground)Releases are automated: conventional commits → release-please PR → merge → npm publish with provenance.
MIT © Oscar Rey