# Tocsin — Animated App UI Block for React

> Notification center block: a bell whose unread badge rolls on Tally digit drums and swings once when something arrives, opening an anchored dialog (or inline panel) with All / Mentions / Archived tabs on Detent, sticky Today / Yesterday / Earlier groups, rich-text rows with avatars or system glyphs, inline Accept/Decline that collapse into a receipt, swipe-left or hover-button archive with a height collapse, staggered Mark all read, and push() that slides new items in with an accent flash. Roving keyboard focus with R / E shortcuts and a polite live region.

- Page: https://crucibleui.com/components/app/tocsin
- Category: [app](https://crucibleui.com/components/app)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/tocsin
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/tocsin`.

## Props (`TocsinProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items?` | `TocsinItem[]` | `a seeded sample inbox` | Initial notifications (the inbox owns read/archived state afterwards and reports it through the callbacks). Passing a new array resets the inbox. |
| `onAction?` | `(item: TocsinItem, actionId: string) => void \| Promise<unknown>` | — | Fired when an inline action is clicked. Return a Promise to hold the button in a pending state — resolve collapses the actions into the receipt line; reject shows the rejection message inline. |
| `onRead?` | `(item: TocsinItem, read: boolean) => void` | — | Fired when a notification is marked read (`read: true`) or unread (`read: false`). |
| `onArchive?` | `(item: TocsinItem, archived: boolean) => void` | — | Fired when a notification is archived (`archived: true`) or restored (`archived: false`). |
| `onItemsChange?` | `(items: TocsinItem[]) => void` | — | Fired after every change with the full list. |
| `inline?` | `boolean` | `false` | Render the panel in place (no bell trigger / popover). |
| `open?` | `boolean` | — | Controlled popover state. |
| `onOpenChange?` | `(open: boolean) => void` | — | Popover open-state changes (bell click, Escape, outside click, `open()`/`close()`). |
| `align?` | `"start" \| "end"` | `"end"` | Popover edge aligned to the bell. |
| `title?` | `string` | `"Notifications"` | Panel heading. |
| `shortcuts?` | `boolean` | `true` | Show the keyboard-shortcut footer (hidden below ~420px wide regardless). |
| `accentColor?` | `string` | `"#fb7185"` | The single accent: badge, unread dots, focus rings, new-item flash, primary actions. Any CSS color; hex gets auto-contrast ink. |
| `ref?` | `React.Ref<TocsinHandle>` | — | Imperative handle — `push()`, `open()`, `close()`, `markAllRead()`, `replay()`, `getItems()`. |
| `className?` | `string` | — | Classes for the root (the panel in `inline` mode, the trigger wrapper otherwise). |
| `panelClassName?` | `string` | — | Classes for the panel (popover mode) — e.g. a width or height. |

## Usage example

```tsx
"use client";

import * as React from "react";

import { Tocsin, type TocsinHandle, type TocsinPushItem } from "@/components/crucible/tocsin";

const INCOMING: TocsinPushItem[] = [
  {
    kind: "mention",
    actor: "Lena Brandt",
    message: "**Lena Brandt** mentioned you in *Roadmap review*",
    excerpt: "Pulling you in for the analytics milestone — does mid-November still hold?",
  },
  {
    kind: "invite",
    actor: "Omar Haddad",
    message: "**Omar Haddad** invited you to *Weekly sync*",
  },
  {
    kind: "comment",
    actor: "Sofia Marin",
    message: "**Sofia Marin** commented on *Checkout redesign*",
    excerpt: "The trust badges read much cleaner without the border.",
  },
  {
    kind: "system",
    message: "Your import of *2,418 contacts* finished without errors",
  },
];

export default function TocsinDemo() {
  const ref = React.useRef<TocsinHandle>(null);
  const next = React.useRef(0);

  const simulate = () => {
    const item = INCOMING[next.current % INCOMING.length];
    next.current += 1;
    ref.current?.push(item);
  };

  return (
    <div className="relative isolate flex h-full min-h-[640px] w-full flex-col items-center justify-center gap-4 overflow-hidden rounded-xl bg-neutral-950 px-4 py-8">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 -z-10"
        style={{
          background:
            "radial-gradient(50% 45% at 50% 30%, rgba(251,113,133,0.07), transparent 70%), radial-gradient(40% 40% at 50% 100%, rgba(255,255,255,0.03), transparent 70%)",
        }}
      />
      <div className="relative z-10 flex w-full flex-col items-center gap-4">
        <Tocsin
          ref={ref}
          inline
          onAction={() => new Promise<void>((resolve) => window.setTimeout(resolve, 700))}
        />
        <div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-xs text-white/45">
          <button
            type="button"
            onClick={simulate}
            className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.04] px-3 font-medium text-white/80 transition-colors hover:bg-white/[0.08] hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
          >
            Simulate notification
          </button>
          <span>Swipe to archive — try a new notification.</span>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
