npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@danko167/narrative-loader

v1.0.0

Published

Human-friendly async feedback for React apps with narrative loading states, tones, animations, emojis, timelines, and backend polling.

Downloads

68

Readme

🧠 Narrative Loader

Human-friendly async feedback for React apps.

Instead of generic spinners, show meaningful, animated status messages like:

Thinking...
→ Interesting...
→ Working on it...
→ Almost done...

Designed for modern apps:

  • dashboards
  • CRUD apps
  • AI / chat interfaces
  • background tasks

✨ Features

  • 🧠 Narrative, human-like loading states
  • 🎭 Built-in variants and tone presets
  • 🔀 Optional randomized message flow
  • ⏱ Delay + minimum visible duration (no flicker)
  • 📡 Backend-driven status polling
  • 🔁 Sequential polling with built-in bounded retry/backoff for transient failures
  • 🎬 Text + emoji animations
  • 🧩 Fully customizable messages
  • ♿ Respects reduced motion
  • ♿ Live-region friendly accessibility semantics

Install

Requires React 18 or newer.

npm install @danko167/narrative-loader

Import the CSS once in your app:

import "@danko167/narrative-loader/styles.css";

Quick start

import { NarrativeLoader } from "@danko167/narrative-loader";

<NarrativeLoader loading />

That uses the default preset sequence.


Custom messages

Custom messages always win over presets.

<NarrativeLoader
  loading={loading}
  messages={[
    "Validating your data",
    "Saving changes",
    "Almost done",
  ]}
/>

You can also pass richer message objects:

<NarrativeLoader
  loading={loading}
  useEmojis
  messages={[
    {
      text: "Validating your data",
      emoji: "🔍",
      animation: "dots",
      emojiAnimation: "pulse",
    },
    {
      text: "Updating records",
      emoji: "💾",
      animation: "fade",
      emojiAnimation: "bounce",
    },
    {
      text: "Finishing up",
      emoji: "✨",
      animation: "typewriter",
      emojiAnimation: "bounce",
    },
  ]}
/>

Variants

Use variants for common async flows:

<NarrativeLoader loading={loading} variant="chat" />
<NarrativeLoader loading={loading} variant="save" />
<NarrativeLoader loading={loading} variant="upload" />
<NarrativeLoader loading={loading} variant="search" />
<NarrativeLoader loading={loading} variant="delete" />
<NarrativeLoader loading={loading} variant="analysis" />

Tones

<NarrativeLoader loading={loading} tone="professional" />
<NarrativeLoader loading={loading} tone="friendly" />
<NarrativeLoader loading={loading} tone="playful" />

Randomized messages

<NarrativeLoader loading={loading} variant="analysis" randomize />

When randomize is used with loop={false}, the loader performs up to messages.length - 1 random transitions and then stays on the last shown message.

<NarrativeLoader loading={loading} messages={["A", "B", "C"]} randomize loop={false} />

This mode does not guarantee that every message appears exactly once.

For large message arrays, prefer loop={false} or a timeline when you want bounded transitions.


Text animations

<NarrativeLoader loading={loading} animation="typewriter" />
<NarrativeLoader loading={loading} animation="dots" />
<NarrativeLoader loading={loading} animation="fade" />
<NarrativeLoader loading={loading} animation="none" />

You can also tune text animation timing in the wrapper component:

<NarrativeLoader
  loading={loading}
  animation="typewriter"
  typewriterInterval={20}
  dotsInterval={300}
/>

Emojis

<NarrativeLoader loading={loading} useEmojis />

Timeline

<NarrativeLoader
  loading={loading}
  timeline={[
    { after: 0, message: "Starting" },
    { after: 2500, message: "Still working" },
    { after: 7000, message: "This is taking longer than usual" },
  ]}
/>

Shorthand objects are also supported:

timeline={[
  { after: 0, text: "Starting", emoji: "🧠", animation: "dots" },
  { after: 2500, text: "Still working", emoji: "⚙️" },
]}

If the first timeline item starts after after > 0, the loader shows a fallback "Working on it" message until that first timeline item becomes active.


Delay & duration

<NarrativeLoader loading={loading} delay={400} />
<NarrativeLoader loading={loading} minVisibleDuration={700} />

Done & error

<NarrativeLoader loading={loading} doneMessage="Done" />
<NarrativeLoader loading={loading} error errorMessage="Something went wrong" />

When loading changes from true to false and doneMessage is provided, the loader enters done state briefly before hiding. Control the visibility window with doneDuration.

Error behavior:

  • error={true} shows errorMessage as-is.
  • error="..." or error={new Error("...")} uses your runtime error text while preserving emoji/animation styling from errorMessage.

Backend polling

<NarrativeLoader
  loading={loading}
  source="/api/jobs/123/status"
  pollInterval={1500}
  doneMessage="Summary ready"
  getMessage={(data) => (data as { step?: string }).step}
  stopWhen={(data) => (data as { done?: boolean }).done === true}
/>

For authenticated or non-GET status checks:

<NarrativeLoader
  loading={loading}
  source="/api/jobs/123/status"
  sourceRequestInit={{
    method: "POST",
    headers: { Authorization: `Bearer ${token}` },
    body: JSON.stringify({ jobId: "123" }),
  }}
/>

If you already use a fetch wrapper, inject it:

<NarrativeLoader
  loading={loading}
  source="/api/jobs/123/status"
  fetcher={(input, init) => apiClient.fetch(input, init)}
/>

This works well with responses like:

{ "step": "Generating summary", "done": false }

getMessage should return a non-empty string when you want to override the displayed polling text. If it returns null, undefined, or an empty string, the loader falls back to response.message and then to the default text.

<NarrativeLoader
  loading={loading}
  source="/api/jobs/123/status"
  getMessage={(data) => {
    const step = (data as { step?: string }).step;
    return step ? `Step: ${step}` : undefined;
  }}
/>
  • pollInterval controls how often the status endpoint is checked.
  • sourceRequestInit lets you pass request options (method, headers, body, etc.).
  • fetcher lets you provide a custom fetch implementation.
  • getMessage maps the response into the displayed loader text.
  • Until the first polling response arrives, the loader keeps showing your configured preset, custom message, or timeline text.
  • Polling starts as soon as loading enters true (it is not delayed by the visual delay prop).
  • While source polling is active, message-level animation metadata is preserved from your current message/timeline step.
  • stopWhen completes the loader cycle once your job is complete. Pair it with doneMessage if you want a final success message before the loader hides.
  • If you do not provide stopWhen, polling continues while loading stays true.
  • After a source-driven cycle completes, the loader stays idle for that same source until you either set loading={false} or provide a new source value.
  • Polling request failures are retried automatically with bounded retries and backoff.
  • Retry policy: up to 3 consecutive failures total (initial failure + 2 retries), with delays based on pollInterval (1x, then 2x, capped at 4x).
  • After repeated polling failures, the loader switches into error state with the request error text.
  • Polling is sequential: a new request is only scheduled after the previous request settles.

Headless usage

import { useNarrativeLoader } from "@danko167/narrative-loader";

const loader = useNarrativeLoader({
  loading,
  source: "/api/jobs/123/status",
  stopWhen: (data) => (data as { done?: boolean }).done === true,
});

return loader.visible ? (
  <div aria-live="polite" aria-busy={loader.status === "loading"}>
    <strong>{loader.status}</strong>
    <span>{loader.text}</span>
    <small>{loader.message.emoji}</small>
  </div>
) : null;

loader.text is the current display string. loader.message carries the same text plus any emoji or animation metadata for custom headless rendering.

Exports

The package exports:

  • NarrativeLoader
  • useNarrativeLoader
  • tonePresets
  • LOADER_VARIANTS, LOADER_TONES, LOADER_ANIMATIONS
  • EMOJI_ANIMATIONS, EMOJI_POSITIONS

Hook result API

useNarrativeLoader returns an object with these fields:

  • visible: true when the loader should be rendered.
  • status: One of "idle" | "loading" | "done" | "error".
  • text: The current display string, including backend polling text when source is active.
  • message: The normalized message object for the current step, including text, emoji, and any message-level animation metadata.
  • animation: The text animation that should be rendered for the current step.
  • emojiAnimation: The emoji animation that should be rendered for the current step.
  • index: The current message or timeline index.
  • isSourceMessage: true while the displayed text is being driven by source polling.

Accessibility

  • The wrapper uses role="status", aria-live="polite", aria-atomic="true", and loading-aware aria-busy.
  • Animated punctuation/typewriter rendering is visual-only while a stable status string is preserved for assistive technologies.

License

MIT