Skip to content

danko167/narrative-loader

Repository files navigation

🧠 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

About

Human-friendly async loader for React with narrative status messages instead of spinners.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors