Skip to content

oscarrc/crust

Repository files navigation

🍞 Crust

npm CI license

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.

Docs & playground →


Why 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-live region, keyboard-reachable dismiss, timers that pause while you read, and prefers-reduced-motion as a first-class theme.
  • 📦 Zero-waste footprint. ESM-only, ~2 KB of JS, one CSS file, no dependencies.

Quickstart

Install the package:

pnpm add @oscarrc/crust

Import 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 default

Then 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.

Usage

1. Pure Astro / vanilla JS — zero React

---
// 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>

2. Astro with React islands

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>

3. React (islands or plain apps)

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>
  );
}

API at a glance

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: {} });
  • duration defaults to 4000ms; Infinity (or 0) means persistent.
  • A toast with a message morphs open on hover/focus/tap to reveal it; the timer pauses while you read.
  • Icons accept an SVG string, an Element, or a factory — lucide and lucide-static work out of the box (lucide-react doesn't; the renderer isn't React).
  • Theme everything via --crust-* custom properties — see the theming docs.

Non-goals

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.

Development

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.

License

MIT © Oscar Rey

About

Don't throw the crust. An opinionated, crisp, and zero-waste toast library built natively for Astro and React.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors