# Vyrn

A tiny, accessible, **dependency-free** toast library for React and Next.js.

**Docs & live playground:** [vyrn.vercel.app](https://vyrn.vercel.app/)

```bash
npm install vyrn
```

```tsx
import { Toaster, toast } from 'vyrn';

export default function App() {
  return (
    <>
      <Toaster />
      <button onClick={() => toast.success('Event created')}>Toast</button>
    </>
  );
}
```

---

## Why Vyrn

- **No runtime dependencies.** 9.3 kB gzipped for the whole library, stylesheet included. v4 pulled in `framer-motion` and `lucide-react`; v5 pulls in nothing.
- **Timers, not animation frames.** Toasts dismiss on schedule even in a background tab. (v4 drove dismissal from `requestAnimationFrame`, which browsers pause when the tab is hidden — toasts piled up forever.)
- **Real accessibility.** One pair of live regions, valid ARIA throughout, keyboard reachable, `prefers-reduced-motion` and `forced-colors` honoured.
- **Measured layout.** Stack offsets come from actual element heights, so multi-line toasts never overlap.
- **Queued, never dropped.** Toasts past `visibleToasts` wait their turn instead of being deleted.
- **Sonner-compatible API**, plus inline inputs, expandable toasts, priorities, sizes, grouping and determinate progress.

---

## Quick start

### React

```tsx
import { Toaster, toast } from 'vyrn';

function App() {
  return (
    <div>
      <Toaster position="bottom-right" richColors />
      <button onClick={() => toast('My first toast')}>Give me a toast</button>
    </div>
  );
}
```

### Next.js (App Router)

`<Toaster />` already carries `'use client'`, so it drops straight into a
server-rendered layout — no `transpilePackages`, no wrapper component.

```tsx
// app/layout.tsx
import { Toaster } from 'vyrn';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <Toaster />
      </body>
    </html>
  );
}
```

```tsx
// app/page.tsx
'use client';
import { toast } from 'vyrn';

export default function Page() {
  return <button onClick={() => toast('Hello')}>Toast</button>;
}
```

> `toast()` runs in the browser, so it must be called from a Client Component,
> an event handler, or an effect — never during server rendering. It is safe to
> call **before** `<Toaster />` mounts: the toast is queued and shown on mount.

---

## `toast`

| Method | Description |
| --- | --- |
| `toast(message, options?)` | Neutral toast. Returns the toast **id**. |
| `toast.message(message, options?)` | Alias for the above. |
| `toast.success(message, options?)` | Success variant. |
| `toast.error(message, options?)` | Error variant. Announced assertively. |
| `toast.info(message, options?)` | Info variant. |
| `toast.warning(message, options?)` | Warning variant (alias: `toast.warn`). |
| `toast.loading(message, options?)` | Spinner; never auto-dismisses. |
| `toast.custom(node \| (id) => node, options?)` | No semantic styling. Accepts a render function receiving the id. |
| `toast.promise(promise, options)` | Loading → success/error in one toast. Returns `{ id, unwrap() }`. |
| `toast.update(id, patch)` | Patch an existing toast in place. |
| `toast.dismiss(id?)` | Dismiss one toast, or **all** when called with no argument. |
| `toast.remove(id)` | Remove without firing `onDismiss`. |
| `toast.clearAll()` | Legacy alias for `toast.dismiss()`. |
| `toast.getToasts()` | Current queue. |
| `toast.getHistory()` | Toasts that already closed. |
| `toast.isActive(id)` | Whether a toast is still queued or on screen. |

### Stable ids and deduplication

Passing an `id` that already exists **updates** that toast instead of adding a
second one:

```tsx
toast.loading('Uploading…', { id: 'upload' });
toast.success('Uploaded', { id: 'upload' }); // same toast, no duplicate
```

### Promises

```tsx
const result = toast.promise(saveSettings(), {
  loading: 'Saving…',
  success: (data) => `Saved ${data.count} settings`,
  error: (err) => (err as Error).message,
  finally: () => setBusy(false),
});

await result.unwrap(); // re-throws on rejection
```

`toast.promise` never produces an unhandled rejection — the promise is settled
internally, and only `unwrap()` re-throws.

### Per-type defaults

Narrow `toastOptions` to a single variant — errors that linger, successes that
flash by:

```tsx
<Toaster
  duration={4000}
  toastOptions={{
    types: {
      error: { duration: 8000, closeButton: true },
      success: { duration: 2000 },
      loading: { closeButton: false },
    },
  }}
/>
```

Precedence is always: per-toast option → per-type default → global default.

### Avoiding duplicates

```tsx
// A retry loop that fires the same error repeatedly shows one toast,
// with its countdown restarted each time.
toast.error('Connection failed', { preventDuplicate: true });
```

Identical text **and** the same type counts as a duplicate. For arbitrary
grouping use `groupId`; for a specific slot use a stable `id`.

### Grouping

Toasts sharing a `groupId` occupy a single slot; each new one replaces the last.
Useful for progress, autosave, and presence.

```tsx
toast(`Uploading ${done}/${total}`, { groupId: 'upload', progress: pct });
```

---

## Toast options

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string \| number` | auto | Stable id. Reusing one updates that toast. |
| `description` | `ReactNode` | — | Secondary line. |
| `duration` | `number` | `4000` (`5000` errors, `Infinity` loading) | ms before auto-dismiss. `0` or `Infinity` disables it. |
| `icon` | `ReactNode` | per type | Override the icon. |
| `action` | `{ label, onClick, style?, dismiss?, altText? }` | — | Single action button (Sonner shape). `altText` names an icon-only button. |
| `actions` | `ToastAction[]` | — | Several action buttons (Vyrn extension). |
| `cancel` | `{ label, onClick?, className?, altText? }` | — | Secondary cancel button. |
| `input` | `{ placeholder, onSubmit, submitLabel?, defaultValue?, allowEmpty? }` | — | Inline text field. Pauses the countdown while focused. |
| `position` | `ToastPosition` | Toaster's | Per-toast placement. |
| `dismissible` | `boolean` | `true` | `false` hides the close button and blocks swipe/Escape. |
| `closeOnClick` | `boolean` | Toaster's | Dismiss when the toast body is clicked. |
| `preventDuplicate` | `boolean` | Toaster's | Refresh the existing toast instead of stacking an identical one. |
| `important` | `boolean` | `false` | Announce assertively regardless of type. |
| `richColors` | `boolean \| 'minimal' \| 'soft' \| 'solid'` | Toaster's | Per-toast override. |
| `invert` | `boolean` | Toaster's | Flip the surface against the page. |
| `closeButton` | `boolean` | Toaster's | Per-toast override. |
| `showProgressBar` | `boolean` | Toaster's | Per-toast override. |
| `progress` | `number` | — | `0`–`100`. Renders a determinate bar instead of a countdown. |
| `size` | `'sm' \| 'md' \| 'lg'` | Toaster's | Density. |
| `priority` | `'low' \| 'normal' \| 'high'` | `'normal'` | Visual emphasis. |
| `expandable` | `boolean` | `false` | Keep actions collapsed until clicked. |
| `expanded` | `boolean` | — | Control the expanded state yourself. |
| `groupId` | `string` | — | Collapse into a single slot with its group. |
| `className` | `string` | — | Class on the toast element. |
| `descriptionClassName` | `string` | — | Class on the description. |
| `classNames` | `ToastClassNames` | — | Per-part classes (see below). |
| `style` | `CSSProperties` | — | Inline styles. |
| `unstyled` | `boolean` | `false` | Strip all built-in styling. |
| `jsx` | `ReactNode \| (id) => ReactNode` | — | Replace the toast's entire contents. |
| `customComponent` | `ComponentType<{ toast, dismiss }>` | — | Replace the toast element. |
| `soundEffect` | `string` | — | URL played when the toast appears. |
| `onClick` | `(e) => void` | — | Makes the whole toast a button. |
| `onDismiss` | `(toast) => void` | — | Closed by the user or programmatically. |
| `onAutoClose` | `(toast) => void` | — | Closed because the duration elapsed. |
| `onClose` | `() => void` | — | Legacy: fires on any close. |

`classNames` accepts: `toast`, `title`, `description`, `icon`, `content`,
`actionButton`, `cancelButton`, `closeButton`, `progressBar`, `input`, and one
key per type (`success`, `error`, `info`, `warning`, `loading`, `default`).

---

## `<Toaster />`

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `position` | `'top-left' \| 'top-center' \| 'top-right' \| 'bottom-left' \| 'bottom-center' \| 'bottom-right'` | `'bottom-right'` | Where toasts mount. |
| `layout` | `'stack' \| 'normal'` | `'normal'` | `stack` collapses toasts into a deck that expands on hover. |
| `visibleToasts` | `number` | `5` | Max on screen. Extras **queue**. |
| `duration` | `number` | `4000` | Default duration for all toasts. |
| `theme` | `'light' \| 'dark' \| 'system'` | `'system'` | `system` follows `prefers-color-scheme`. |
| `dir` | `'ltr' \| 'rtl' \| 'auto'` | `'auto'` | Text direction; `auto` reads `document.dir`. |
| `richColors` | `boolean \| 'minimal' \| 'soft' \| 'solid'` | `false` | `true` maps to `'minimal'`. |
| `expand` | `boolean` | `false` | Keep a `stack` layout permanently expanded. |
| `closeButton` | `boolean` | `true` | Show the close button. |
| `showProgressBar` | `boolean` | `true` | Show the countdown bar. |
| `color` | `boolean` | `true` | Apply a subtle semantic tint per type. |
| `invert` | `boolean` | `false` | Invert every toast. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Global density. |
| `gap` | `number` | `14` | Gap between toasts, px. |
| `offset` | `string \| number \| { top, right, bottom, left }` | `'24px'` | Distance from the viewport edge. |
| `mobileOffset` | `string \| number \| { top, right, bottom, left }` | `'16px'` | Same, below 600 px wide. |
| `toastOptions` | `ToastOptions & { types? }` | — | Defaults merged into every toast; `types` narrows them per variant. |
| `className` | `string` | — | Class on the toast list. |
| `style` | `CSSProperties` | — | Styles on the toast list. |
| `swipeDirections` | `SwipeDirection[]` | derived from `position` | Directions a toast can be swiped away. |
| `swipeThreshold` | `number` | `0.35` | Fraction of the toast that must be travelled. |
| `hotkey` | `string[]` | `['altKey', 'KeyT']` | Moves focus into the toast list. |
| `pauseWhenPageIsHidden` | `boolean` | `false` | Pause timers while the tab is hidden. |
| `pauseOnFocusLoss` | `boolean` | `false` | Pause timers while the window is not focused. |
| `newestFirst` | `boolean` | `true` | Newest toast nearest the viewport edge. `false` keeps the oldest in front. |
| `closeOnClick` | `boolean` | `false` | Dismiss a toast when its body is clicked. |
| `containerAriaLabel` | `string` | `'Notifications'` | Label for the toast region. |
| `closeButtonAriaLabel` | `string` | `'Close'` | Label for the close button. Translate for i18n. |
| `icons` | `{ success, info, warning, error, loading, close }` | built-in | Replace any icon. |
| `zIndex` | `number` | `9999` | Base stacking order. |

Multiple `<Toaster />` instances are safe: only the first one still mounted
paints the toasts, so a stray `<Toaster />` in a nested layout can never
double-render. When it unmounts, the next one takes over automatically.

`toastOptions.duration` is honoured, with `duration` taking precedence over it,
and a per-toast `duration` over both.

---

## Headless

Render the queue yourself:

```tsx
import { useVyrn, toast } from 'vyrn';

function MyToasts() {
  const { toasts } = useVyrn();
  return (
    <ul>
      {toasts.map((t) => (
        <li key={t.id}>
          {t.content}
          <button onClick={() => toast.dismiss(t.id)}>x</button>
        </li>
      ))}
    </ul>
  );
}
```

`useSonner` is exported as an alias.

---

## Theming

Every value is a custom property on `.vyrn-toast-list`:

```css
.vyrn-toast-list {
  --vyrn-bg: #fff;
  --vyrn-fg: #0f172a;
  --vyrn-muted: rgba(15, 23, 42, 0.62);
  --vyrn-border: #e5e7eb;
  --vyrn-shadow: 0 4px 14px rgba(15, 23, 42, 0.1);
  --vyrn-accent: #64748b; /* icon + progress bar; overridden per type */
  --vyrn-radius: 12px;
  --vyrn-width: 356px;
  --vyrn-pad: 16px;
}
```

### Dark mode

With the default `theme="system"`, Vyrn resolves in this order — **the host app
outranks the OS**, so a site toggled to light on a dark machine gets light
toasts:

1. An explicit `theme="light"` / `theme="dark"` prop.
2. What your app declares, checked on `<html>` then `<body>`:
   - a `.dark` or `.light` class — Tailwind `darkMode: 'class'`, next-themes `attribute="class"`
   - a `data-theme="dark|light"` attribute — next-themes `attribute="data-theme"`, DaisyUI
   - an inline `color-scheme` — next-themes sets this by default
3. `prefers-color-scheme`, if your app declares nothing.

Changes are picked up live via a `MutationObserver`, so an already-visible toast
flips when the user toggles the theme.

### Tailwind CSS

Nothing to configure. Vyrn's stylesheet is injected at the **top** of `<head>`,
so your Tailwind utilities load later and win on equal specificity — verified:
`className="bg-zinc-900 rounded-full p-0"` overrides Vyrn's background, radius
and padding. The `data-*` variants set CSS variables rather than final
properties, so literal utilities beat them too.

```tsx
<Toaster
  toastOptions={{
    classNames: {
      toast: 'bg-white dark:bg-zinc-950 border-zinc-200 dark:border-zinc-800',
      title: 'font-semibold text-zinc-900 dark:text-zinc-100',
      description: 'text-zinc-500 dark:text-zinc-400',
      actionButton: 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900',
    },
  }}
/>
```

`dark:` variants work as normal, since your `.dark` class sits on an ancestor.
For a completely blank slate use `unstyled: true` and style every part yourself.

For structural styling, target data attributes rather than class names:
`data-type`, `data-size`, `data-mounted`, `data-removed`, `data-front`,
`data-expanded`, `data-swiping`, `data-invert`, `data-unstyled`,
`data-priority`, `data-rich-colors`.

---

## Accessibility

- The toast list is a labelled `<ol>` inside a `role="region"`; each toast is an `<li>`.
- Announcements come from **one** pair of off-screen live regions (polite + assertive). Toasts carry no live semantics, so nothing is read twice and dismissed toasts are never re-announced.
- Errors and `important: true` announce assertively; everything else politely.
- Every toast is focusable. <kbd>Alt</kbd>+<kbd>T</kbd> jumps into the list, <kbd>Escape</kbd> dismisses, <kbd>Enter</kbd>/<kbd>Space</kbd> activates a clickable or expandable toast.
- Icon-only action buttons take `altText` for their accessible name, so they are never announced as just "button".
- Hovering or focusing the list pauses **every** countdown, not just one.
- Dismissing a focused toast moves focus to the next toast, or back to whatever was focused before you entered the list — never to `<body>`.
- `prefers-reduced-motion: reduce` removes all motion; `forced-colors: active` is supported.

### Robustness

- A throwing `onDismiss`, `onAutoClose`, `onClose` or store subscriber is caught and logged; it can never leave the queue in a broken state.
- `progress` is clamped to 0–100; negative and non-finite durations are treated as persistent rather than firing instantly.
- Swipes use pointer capture, so a drag that leaves the toast still completes instead of sticking.
- Server rendering emits no toast markup at all, so hydration can never mismatch — even if your bundle dispatched a toast before `<Toaster />` mounted.

---

## Compatibility

| | Supported |
| --- | --- |
| React | 18.x, 19.x |
| Next.js | 14.x, 15.x, 16.x (App Router and Pages Router) |

React 17 is no longer supported — see [MIGRATION.md](./MIGRATION.md).

---

## Migrating

- **From Vyrn v4** — see [MIGRATION.md](./MIGRATION.md). The v4 API still works; a couple of defaults changed.
- **From Sonner** — the `toast` API and most `<Toaster />` props are drop-in. See the Sonner section of [MIGRATION.md](./MIGRATION.md).

## Development

```bash
npm install
npm test          # 140 tests
npm run typecheck
npm run build
npm run size      # bundle budget
```

## License

MIT © Vishal Yadav
