zxkit

@zxkit/noti

One notification, one island.

A React notification with a single mental model: an object-only API, one live instance, and a pill that morphs into a card and back on the same DOM node. Timers and async settlements are protected against stale state, and nothing interactive is nested inside another control.

bun add @zxkit/notiDocumentation

Live

mounted in the layout

A single <NotiOutlet /> sits in this route's layout, with the package's own stylesheet and no overrides. Hover or tap the island to open it and hold its countdown, or throw it out towards the edge it is anchored to.

No decision yet.

Notification buttons replace the same island — press them as fast as you like and there is still exactly one [data-noti-item] on the page, on the same DOM node. Hover it — or tap it, where nothing can hover — to hold its countdown, tab into it to hold it again, and switch browser tabs to hold it a third way: three independent reasons on one timer.

Confirm via notification stays expanded until you choose Cancel or Delete. It uses ordinary notification actions, so another notification can still replace it.

Live example · useNoti()
Show a notification, then unmount its panel. Try unmounting during the task, too: the late result will stay closed.

Panel mounted

Setup

3 snippets

One outlet in the root layout, then calls from anywhere in the app.

Mount

NotiOutlet

One outlet in your root layout and you are done. It publishes its position and defaults to the store, holds the countdown while the tab is hidden, and unregisters cleanly on unmount.

// app/layout.tsx — no stylesheet to import.
import { NotiOutlet } from '@zxkit/noti'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body>
        {children}
        <NotiOutlet position='top-right' closeButton />
      </body>
    </html>
  )
}

Imperative

noti.success({ … })

Callable from anywhere — an event handler, a server action wrapper, a websocket callback. Every method takes an options object; there is no string form to fall back to.

import { noti } from '@zxkit/noti'

noti.success({ title: 'Changes saved' })
noti.warning({ title: 'Session expiring' })
noti.error({
  title: 'Could not save',
  description: 'Try again in a few minutes.',
})

// Works outside React: no hook, no provider, no context.
export async function saveDraft(draft: Draft) {
  await db.drafts.upsert(draft)
  noti.success({ title: 'Draft saved' })
}

Component lifetime

useNoti()

The same methods in a stable API bound to your component. Its live notification closes automatically on unmount. Replacements from other hooks or the global API remain visible; all calls share the same island and replacement priorities.

'use client'

import { useNoti } from '@zxkit/noti'

export function NotificationDemo() {
  const noti = useNoti()

  return (
    <button
      onClick={() => {
        noti.info({
          title: 'A notification for this view',
          duration: null,
        })
      }}
    >
      Show notification
    </button>
  )
}

// The same NotiOutlet renders it. No extra provider or manual cleanup.
// update(), dismiss() and clear() affect only this hook's live notification.
// promise() keeps that ownership from loading to its outcome. Unmounting
// closes the notification without cancelling the underlying operation.
// A late result cannot bring the dismissed notification back.

// Use the module-level noti when a notification should survive its component.
// A component that stays active across navigation keeps its notifications.

Behaviour

6 snippets

What happens when calls overlap, promises settle late, and countdowns are held from three directions at once.

Singleton

one notification

A second call replaces the first instead of stacking on it. Unique invocation ids, same DOM node, so the change is a morph rather than a mount.

noti.success({ title: 'Changes saved' })
noti.error({ title: 'Could not save' })   // replaces it — same node, same island

// Every call returns a unique id. A stale id cannot close a newer notification.
const id = noti.info({ title: 'New comment' })
noti.dismiss(id)

// A call that arrives while the island is open collapses it first, swaps the
// content, then lets autopilot open it again. Never a card swapped for a card.

Options

the whole contract

Content, placement, timing, geometry and per-slot styles in one object. Anything omitted falls back to the outlet, then to the library.

noti.show({
  type: 'success',
  title: 'Changes saved',
  description: 'All 12 records synced.',

  position: 'top-right',      // omitted keeps the live position
  duration: 6000,             // null, 0, negatives and Infinity are sticky
  autopilot: { expand: 150, collapse: 4000 },

  icon: <CheckIcon />,        // null removes the badge entirely
  styles: { title: 'font-semibold', description: 'text-xs' },
  fill: '#1a1a1a',
  roundness: 16,
})

Promise

noti.promise

Loading, then success, error or an action with a button — from one call. You get the untouched promise back and the error stays typed as unknown.

const project = await noti.promise(saveProject(), {
  loading: { title: 'Saving project…' },
  success: (value) => ({ title: 'Project saved', description: value.name }),
  error: (error) => ({ title: 'Could not save', description: toMessage(error) }),
  action: (value) => ({
    title: 'Project ready',
    button: { title: 'Open', onClick: () => openProject(value) },
  }),
})

// The original promise comes back: it resolves and rejects exactly as it would
// have without noti, and the error stays typed as unknown.

Staleness

latest invocation wins

Every call mints a new instance. A settlement that no longer owns the island is dropped, so a slow request cannot overwrite what replaced it or resurrect what you dismissed.

// A slow request starts…
void noti.promise(slowSave(), {
  loading: { title: 'Saving…' },
  success: { title: 'Saved' },
})

// …and something more important happens before it settles.
noti.error({ title: 'Connection lost' })

// The slow result lands on nothing: it no longer owns the island, so it cannot
// overwrite it or bring a dismissed notification back. Latest invocation wins.

Action

one button

A real button, sibling to the live region, never nested inside another control. It acts and leaves the notification standing.

noti.action({
  title: 'File uploaded',
  description: 'Share it with your team?',
  button: {
    title: 'Share now',
    onClick: () => share(file),
    accessibleLabel: 'Share the uploaded file',
  },
})

// The button does not close the notification. A control that dismisses what it
// just confirmed takes the confirmation away.
// Pending actions block repeated clicks. Failures show an error and pause expiry.

Timers

holds and restarts

Pausing is a set of reasons, not a boolean, so hover and focus cannot cancel each other out. A replacement restarts the countdown by identity, not by duration.

// Hover, focus and a hidden tab are three independent holds on one countdown.
// It resumes only when the last of them is gone.

// A replacement restarts the countdown even when the duration is identical:
// in a singleton, milliseconds alone cannot tell one notification from another.
noti.success({ title: 'Saved', duration: 4000 })
noti.success({ title: 'Saved again', duration: 4000 })  // full 4s, not 4s minus

// null, 0, negatives and Infinity all mean "never auto-close".
noti.error({ title: 'Payment declined', duration: null })

Presentation

3 snippets

Glyphs, tokens and markup — the parts you are meant to replace.

Icons

your own glyphs

Replace the built-in glyph for the states you name. A state you leave out keeps its own, null drops the badge, and a call's own icon still wins over both.

<NotiOutlet
  position='bottom-right'
  closeButton
  icons={{
    error: CircleXIcon,                  // the component itself
    success: CircleCheckIcon,
    info: <InfoIcon strokeWidth={3} />,  // an element, when it needs props
  }}
/>

// A state you leave out keeps its built-in glyph.
// null drops the badge for that state entirely.
<NotiOutlet icons={{ loading: null }} />

// Three layers, narrowest first: the call, then the outlet, then the built-ins.
noti.error({ title: 'Failed', icon: CircleXIcon })   // wins over icons.error
noti.error({ title: 'Failed', icon: null })          // no badge at all

// Passing the component itself needs a client boundary: React Server
// Components cannot serialize a function. Mark the file 'use client', or use
// the element form, which crosses that boundary fine.

// Presentation, resolved at render rather than baked into the record — so
// changing it restyles the notification that is already on screen.

Styling

tokens and slots

A stylesheet you import, tokens you override, a class per slot, styles per call, or nothing at all. The package is not coupled to any CSS framework.

/* The CSS ships inside the JS: the outlet injects it on mount, first in
   <head>, so your own stylesheet still wins at equal specificity. */
<NotiOutlet nonce={cspNonce} />          // for a strict Content Security Policy
<NotiOutlet injectStyles={false} />      // you own it: import '@zxkit/noti/styles.css'

/* Every value is a token. Override them anywhere the cascade reaches. */
:root {
  --noti-width: 380px;
  --noti-compact-height: 40px;
  --noti-success: oklch(0.7 0.18 160);
}

/* Or style by slot, in whatever CSS system you already use. */
<NotiOutlet
  classNames={{ item: 'shadow-2xl', title: 'text-sm font-medium' }}
  options={{ styles: { description: 'text-xs' } }}
/>

/* A call's styles win over the outlet's, one slot at a time. */
noti.success({ title: 'Saved', styles: { title: 'text-emerald-400' } })

/* Or keep the behaviour and the semantics, and none of the appearance. */
<NotiOutlet unstyled classNames={{ item: 'my-island' }} />

Accessibility

the markup

Polite by default, assertive only on request, no control nested inside another, and focus that stays exactly where the user left it.

// The root is an li, never a button. Making the whole island a control with
// its action nested inside is exactly what no screen reader or keyboard user
// can untangle.
<li data-noti-item data-noti-state='action' data-noti-phase='visible'>
  <svg data-noti-island-canvas aria-hidden='true' />
  <div role='status' aria-live='polite' aria-atomic='true'>
    <span data-noti-icon aria-hidden='true' />
    <div data-noti-title>File uploaded</div>
    <div data-noti-description>Share it with your team?</div>
  </div>
  <div data-noti-actions>
    <button data-noti-button>Share now</button>
    <button data-noti-close aria-label='Close notification'>…</button>
  </div>
</li>

// Assertive announcements are opt-in, even for errors.
noti.error({ title: 'Payment declined', important: true })

Design

8 decisions
One island
The store holds a record or nothing at all. No stack, no queue, no limit, no second outlet — a new call replaces the live one, even mid-exit.
Objects only
Every creation method takes one options object. There is no string form and no positional second argument, so a call reads the same everywhere.
Latest invocation wins
Each call mints a new instance. A stale timer, exit or promise settlement is dropped rather than applied to the notification that replaced it.
Physical refresh
An open island collapses before it changes what it says, then opens again. Same DOM node, one continuous silhouette, never a card swapped for a card.
Timers that hold
Hover, focus, a hidden document and an explicit pause are separate reasons. The countdown resumes only when the last one is gone.
Accessible by construction
A structural li, one polite live region, a real button as its sibling, and no control nested inside another. Focus never moves on its own.
No animation dependency
The silhouette is an SVG filter, the morph is the Web Animations API on measured values, and the rest is CSS. Interruptible, and zero runtime deps.
Yours to style
A stylesheet you import, tokens you override, a class per slot, per-call styles, or unstyled. Never coupled to a CSS framework.