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

@michaelandrewgamble/capicola

v0.2.4

Published

A TikTok/CapCut-style narrated, word-by-word caption component for React, pinned to an anchor element.

Readme


Capicola renders a narrated, word-by-word caption — the animated karaoke style you see on TikTok and CapCut — and pins it to any anchor element (or lays it out inline). Feed it plain text and it paces the highlight itself with a research-tuned cadence model; feed it word-level timings plus an audio file and the highlight rides the narration exactly. It ships four ready-made presets, a featured-quote mode, and is fully themeable through typed tokens or raw CSS variables. At its core it's a framework-agnostic engine (createCapicola) with zero runtime dependencies; a React component (capicola/react) and a web component (capicola/web-component) are thin adapters on top.

Table of contents

Features

  • Two drive modes — silent cadence mode (just pass text) or synced audio mode (pass words + audioSrc).
  • Framework-agnostic core — a headless createCapicola(el, opts) engine with zero runtime dependencies; ships React and web-component adapters. No design-system imports, no CSS-in-JS runtime.
  • Themeable, two ways — a typed appearance prop or raw --cap-* CSS custom properties. Every token maps 1:1 to a variable.
  • Four presetsbox, color, bubble, plain, each a full bundle of tokens you can still override.
  • Featured-quote mode — turn the same engine into an auto-cycling quote reel with a styled author attribution.
  • Anti-jitter by design — the highlight is a paint change over constant padding; the pop is a compositor transform: scale(). Nothing reflows per word, ever.
  • Smart anchoring — a 3×3 anchor grid plus collision-aware auto vertical placement that flips above/below to stay on-screen and tracks the anchor as the page scrolls.
  • CapCut-style chunking — group words into on-screen "pages" by pause or by box width, with sentence-aware breaks and a max-lines cap.
  • Accessible & motion-aware — the full caption is exposed to assistive tech as one labeled group (no chatty per-word announcements), and motion fully respects prefers-reduced-motion.
  • Caption CLInpx capicola-caption generates word timings from existing audio (WhisperX) or from TTS with word marks (Amazon Polly, ElevenLabs).

Install

npm install capicola   # or: pnpm add capicola / yarn add capicola

Capicola ships as a framework-agnostic core + thin adapters, via subpath exports:

| Import | What you get | | ------------------------ | ---------------------------------------------------------------------------------------- | | capicola | The headless engine createCapicola(el, opts) + the pure functions/types. No framework. | | capicola/react | The <Capicola> React component. | | capicola/web-component | The <capicola-caption> custom element (works anywhere). | | capicola/styles.css | The stylesheet (import once) — every --cap-* default. |

react is an optional peer dependency (>=18), needed only for capicola/react. There is no react-dom dependency. Whichever entry you use, import the stylesheet once in your app entry:

import "capicola/styles.css"

Upgrading from 0.1.x? The React component moved from the package root to the /react subpath: change import { Capicola } from "capicola"import { Capicola } from "capicola/react". Nothing else changed — same props, same CSS import.

Quickstart (React)

Cadence mode (no audio)

Pass text and Capicola computes per-word timings from its cadence model.

import { useRef } from "react"
import { Capicola } from "capicola/react"
import "capicola/styles.css"

function Example() {
  const anchor = useRef<HTMLDivElement>(null)
  return (
    <div>
      <div ref={anchor}>Anchor me</div>
      <Capicola open anchorRef={anchor} text="This caption paces itself, word by word." />
    </div>
  )
}

Audio mode (synced to narration)

Pass words (word-level timings, in seconds) and an audioSrc. The highlight is driven from the audio element's currentTime. A CaptionData JSON from the caption CLI spreads straight in.

<Capicola
  open
  anchorRef={anchor}
  audioSrc="/narration.mp3"
  words={[
    { text: "This", start: 0.0, end: 0.32 },
    { text: "rides", start: 0.34, end: 0.71 },
    { text: "the", start: 0.72, end: 0.83 },
    { text: "audio.", start: 0.85, end: 1.4 },
  ]}
/>

// …or with a generated caption file:
// import caption from "./narration.caption.json"
// <Capicola open anchorRef={anchor} {...caption} />

Framework-agnostic engine (createCapicola)

The React component is a thin wrapper over a headless engine that touches the DOM directly — no framework required. Import it from the package root and drive any element:

import { createCapicola } from "capicola"
import "capicola/styles.css"

const cap = createCapicola(document.getElementById("host")!, {
  text: "This caption paces itself, word by word.",
  placement: "inline", // or "anchored" with an `anchorEl`
})

// cap.play() / cap.pause() / cap.update({ preset: "color" }) / cap.destroy()

createCapicola(mountEl, options) returns { play, pause, update, destroy }. The options mirror the React props (with anchorEl: HTMLElement in place of anchorRef). Everything else — presets, appearance, quote mode, cadence, chunking — works the same. This is the layer Vue/Svelte/Angular/Solid adapters would build on; the pure computeCadence, chunkWords, and quote-sequencer helpers are exported from the root too.

Web component (<capicola-caption>)

Drop-in for any framework or plain HTML. Registering the element is a side-effect import:

import "capicola/web-component"
import "capicola/styles.css"
<capicola-caption
  text="This caption paces itself, word by word."
  preset="color"
></capicola-caption>

String props are attributes (text, mode, placement, preset, width, align, anchor-x, anchor-y, offset); object props are set in JS (el.quotes = [...], el.appearance = {...}, el.anchorEl = ...). It renders in light DOM by default (both placements, uses the global stylesheet); add the shadow attribute for a self-contained shadow root (styles adopted automatically, inline placement only).

Presets

Set preset to pick a named style template, then override any individual token with appearance (appearance wins). Tokens are orthogonal, so combinations Just Work — e.g. preset="bubble" plus an appearance.highlightColor gives a per-word box on top of a line bubble.

| Preset | Description | | -------- | ----------------------------------------------------------------------------------------------------------------------- | | box | Condensed heavy caps with a pink gradient box behind the active word. The signature look (also the stylesheet default). | | color | Heavy Inter with a black outline; the active word recolours to gold — no box. | | bubble | Clean semibold Inter, no outline, on a translucent dark bubble behind the whole line. Subtitle-sized. | | plain | Heavy Inter with a black outline and no per-word highlight or pop at all. |

// Preset as-is:
<Capicola open anchorRef={anchor} text="…" preset="bubble" />

// Preset + targeted overrides:
<Capicola
  open
  anchorRef={anchor}
  text="…"
  preset="color"
  appearance={{ highlightTextColor: "#38BDF8", fontSizePx: 40 }}
/>

Note: when no preset is set, the component renders the stylesheet defaults directly — which closely match the box look.

Placement

placement chooses where the caption renders, independent of everything else (preset, mode, drive mode):

  • "anchored" (default) — the classic overlay. Capicola appends into document.body, renders position: fixed, and positions itself against the anchor with the 3×3 anchor grid and collision-aware flip. This is the original behaviour.
  • "inline" — a normal in-flow block. The caption renders position: relative right where <Capicola> sits in your tree, participating in layout like any other element. anchorRef is ignored, and so are the anchoring props (anchorX, anchorY, offset).
// Anchored (default) — pinned overlay, needs an anchorRef:
<Capicola open anchorRef={anchor} text="Pinned to the target." />

// Inline — flows in the document, no anchor:
<Capicola placement="inline" text="Rendered right here, in flow." />

Backward compatible. placement defaults to "anchored", so existing <Capicola open anchorRef text /> usage is unchanged. open now defaults to true (so inline/quote content autoplays on mount), and anchorRef is only required when placement="anchored".

Quote mode

Set mode="quote" and pass quotes to turn Capicola into a featured-quote reel. The whole quote is visible at once and the highlight sweeps only the quote words, left to right; a separately-styled author attribution stays static beside it. After the sweep the reel dwells on the author (a read-pause), then crossfades to the next quote and — by default — loops.

<Capicola
  placement="inline"
  mode="quote"
  quotes={[
    { text: "Simplicity is the ultimate sophistication.", author: "Leonardo da Vinci" },
    {
      text: "The details are not the details. They make the design.",
      author: "Charles Eames",
    },
  ]}
/>

Behaviour:

  • Whole quote visible — quote mode forces a single chunk, so every word shows at once; the sweep is a highlight moving across the static line, not a paging animation.
  • Author never highlighted — the author renders as its own element (never part of the swept words), so the highlight can never land on it. Style it via authorAppearance (see below).
  • Author dwell — after a quote finishes sweeping, the reel holds on the author for authorPauseMs (default 1600) before advancing.
  • Auto-cycle + loop — the reel walks quotes in order. With loop (default true) it wraps from the last quote back to the first after loopPauseMs (defaults to authorPauseMs); with loop: false it freezes on the last quote's author and stops.
  • Crossfade — quote-to-quote transitions crossfade, reusing the page-fade tokens (--cap-scroll-duration / --cap-scroll-easing).
  • Accessible — each quote is exposed to assistive tech as a single labeled group reading quote — author; the decorative quotation marks and separator are aria-hidden. Motion respects prefers-reduced-motion.

Quotation marks & separator

Capicola wraps the quote text in typographic quotation marks and prepends a separator to the author. Each mark is individually configurable through the quote options, and any of them can be set to "" (empty string) to render none:

<Capicola
  placement="inline"
  mode="quote"
  quotes={QUOTES}
  quote={{
    authorPauseMs: 2000,
    openQuote: "«",
    closeQuote: "»",
    authorSeparator: "~ ",
  }}
/>

// Bare quote, no marks or separator:
<Capicola
  placement="inline"
  mode="quote"
  quotes={QUOTES}
  quote={{ openQuote: "", closeQuote: "", authorSeparator: "" }}
/>

Styling the author

The author attribution has its own theme, authorAppearance, with the exact same token shape as appearance. It maps to a parallel set of --cap-author-* CSS variables (see the token table), so you can give the author a different font, size, weight, or colour from the quote body. Author tokens mirror the quote's typography/stroke/shadow but never carry the per-word highlight box.

<Capicola
  placement="inline"
  mode="quote"
  quotes={QUOTES}
  appearance={{ fontSizePx: 40 }}
  authorAppearance={{
    fontSizePx: 22,
    fontWeight: 600,
    textColor: "#9CA3AF",
    textTransform: "none",
  }}
/>

Theming

Set a preset, then override any individual token through the typed appearance prop (or raw --cap-* CSS variables). Every token maps 1:1 to a CSS custom property — so a config reads exactly like the result it produces.

There are two equivalent routes; pick whichever fits your codebase. appearance is a thin typed wrapper that writes the very same --cap-* variables.

1. The appearance prop (typed tokens):

<Capicola
  open
  anchorRef={anchor}
  text="…"
  appearance={{
    fontFamily: "'Anton', sans-serif",
    fontSizePx: 44,
    highlightColor: "#111827",
    highlightTextColor: "#F9FAFB",
    highlightRadiusPx: 12,
  }}
/>

2. Raw --cap-* CSS variables:

<Capicola open anchorRef={anchor} text="…" className="my-caption" />
.my-caption {
  --cap-font-size: 44px;
  --cap-highlight-color: #111827;
  --cap-highlight-text-color: #f9fafb;
  --cap-highlight-radius: 12px;
}

Precedence is: stylesheet defaults → presetappearance (and inline --cap-* written by appearance) → any --cap-* you set via className.

Every prop and token is also a live control in the Storybook playground — a live preview with a searchable Google-font picker up top, then settings, per-color opacity, stroke/shadow, chunking, layout, and cadence:

How it works

Cadence

In cadence mode Capicola derives per-word timings from text — no audio required. The reading model (default) holds each word for charCount / cps seconds, clamped between minWordDuration and maxWordDuration, so pacing scales directly with the cps dial and stays comfortable to read along with. The speech model instead uses a prosody model (function-word reduction, phrase-final lengthening) that sounds like spoken narration. Both models add a commaPause after commas/semicolons/colons and a longer sentencePause after sentence enders, so the highlight breathes at punctuation.

Chunking

Words are grouped into on-screen "pages" that the highlight sweeps across. pause mode (CapCut's behaviour) cuts a new page when the gap between two words exceeds gapThreshold, after a sentence-ending word (when breakOnPunctuation), or at maxWords. width mode ignores gaps and greedily packs as many words as fit the resolved box width, wrapping up to maxLines before paging. During a punctuation beat (when no word is active) the current page is held rather than snapping back to the first page.

Anchoring

The caption is portaled to document.body and positioned fixed against anchorRef. anchorX (left/center/right) and anchorY (top/middle/bottom) form a 3×3 grid — e.g. top+center sits above and centred, middle+center overlays the target, middle+left sits to its left. Edge positions are pushed out by offset px. Set anchorY="auto" for collision awareness: it prefers above, flips below when there isn't room above in the viewport, and re-evaluates as the page scrolls so the caption stays visible. Position tracks the anchor live via ResizeObserver plus scroll/resize listeners.

API reference

The <Capicola> props below are also the engine's options — createCapicola(el, options) and <capicola-caption> take the same surface, except anchorRef (a React ref) becomes anchorEl (a raw HTMLElement).

CapicolaProps

| Prop | Type | Default | Description | | ------------------ | ------------------------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | open | boolean | true | Mounts + plays when true; resets and hides when false. | | anchorRef | React.RefObject<HTMLElement \| null> | undefined | The element the caption is positioned against. Required (and only used) when placement="anchored". | | placement | "anchored" \| "inline" | "anchored" | Where the caption renders: pinned overlay portaled to document.body, or a normal in-flow block. See Placement. | | mode | "caption" \| "quote" | "caption" | What the engine sweeps: the rolling word-by-word caption, or the featured-quote reel. See Quote mode. | | quotes | Quote[] | undefined | The featured quotes for mode="quote"; the reel cycles through them in order. Ignored in caption mode. | | authorAppearance | CaptionTheme | undefined | Aesthetic overrides for the quote author (quote mode), applied as --cap-author-* variables. | | quote | QuoteOptions | see below | Tuning for the quote reel (pauses, looping, quotation marks, separator). | | audioSrc | string | undefined | Audio-mode narration URL/path. Provide alongside words. | | words | WordTiming[] | undefined | Audio-mode word timings (seconds). When present, drives the highlight from the audio clock. | | text | string | undefined | Cadence-mode text; per-word timings are computed from cadence. | | cadence | CadenceOptions | see below | Tuning for cadence mode's per-word pacing. | | chunking | ChunkingOptions | see below | How words are grouped into on-screen pages. | | width | number \| "parent" \| "auto" | "auto" | Box width source: hug content ("auto"), match the caption's flow container ("parent", live — the anchor's parent when anchored, the mount element when inline), or a max width in px (number). | | align | "left" \| "center" \| "right" | "center" | Horizontal alignment of the text within the box when the box is wider than the content. | | balance | boolean | false | Balance wrapped lines to the narrowest width that keeps the same line count — even lines, no orphan word (the react-wrap-balancer effect, on the word track). In quote mode it is also sentence-aware: each sentence starts on its own line, so a word never strands onto a line with a different sentence. Needs a resolved width (number | "parent"); no-op for single-line captions. | | anchorX | "left" \| "center" \| "right" | "center" | Horizontal anchor position relative to the target. | | anchorY | "top" \| "middle" \| "bottom" \| "auto" | "top" | Vertical anchor position: above / over / below the target, or collision-aware "auto". | | offset | number | 8 | Gap (px) pushed outward for edge positions. Ignored for center/middle. | | preset | "box" \| "color" \| "bubble" \| "plain" | undefined | Named style template; appearance merges on top. | | appearance | CaptionTheme | undefined | Aesthetic token overrides, merged over the preset (or defaults) and applied as --cap-* variables. | | onWordChange | (index: number, word: WordTiming) => void | undefined | Fires whenever the active word index changes. Good for analytics. | | onEnded | () => void | undefined | Fires once the sequence/audio completes. | | className | string | undefined | Extra class on the caption root — the escape hatch for raw --cap-* overrides. |

Provide either text (cadence mode) or words + audioSrc (audio mode). If both words and text are supplied, words wins.

WordTiming

| Field | Type | Description | | ------- | -------- | ---------------------------------------------------- | | text | string | The word to display. | | start | number | Seconds from start when the word becomes active. | | end | number | Seconds from start when the word stops being active. |

CadenceOptions (cadence mode)

| Option | Type | Default | Description | | ------------------- | ----------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | style | "reading" \| "speech" | "reading" | Pacing model. reading is char-proportional (subtitle CPS, tuned for comprehension); speech is a prosody model that sounds like speech. | | cps | number | 15 | Characters per second (reading model). ~15 comfortable, ~25 fast. | | minWordDuration | number | 0.2 | Per-word floor, seconds — keeps a highlight trackable. | | maxWordDuration | number | 0.7 | Per-word ceiling, seconds — long words don't stall. | | commaPause | number | 0.8 | Extra dwell after a comma / semicolon / colon, seconds (both models). The highlight clears during the beat. | | sentencePause | number | 0.8 | Extra dwell after a sentence ender (. ! ?), seconds (both models). The highlight clears during the beat. | | rate | number | 165 | Approximate words-per-minute baseline (speech model). | | perSyllable | number | 0.05 | Seconds added per syllable beyond the first (speech model). | | functionWordScale | number | 0.62 | Multiplier for unstressed function words (speech model). | | phraseFinalScale | number | 1.18 | Multiplier for the last word before a boundary (speech model). |

ChunkingOptions

| Option | Type | Default | Description | | -------------------- | -------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | mode | "pause" \| "width" | "pause" | pause cuts pages on word-gaps + sentence punctuation (CapCut-style); width greedily packs as many words as fit the box. | | maxWords | number | 4 | Hard cap on words per page (both modes). | | gapThreshold | number | 0.5 | Pause mode: a gap (seconds) larger than this between two words starts a new page. | | breakOnPunctuation | boolean | true | Always end a page after a sentence-ending word, even mid-pack. | | maxLines | number | 2 | Max lines a page may wrap to before paging. Only engages when a box width is resolved (width = number | "parent"); single-line under "auto". |

width chunking requires a resolved numeric box width. Under width: "auto" it falls back to maxWords-only packing.

Quote (quote mode)

| Field | Type | Description | | -------- | -------- | ---------------------------------------------------------------------- | | text | string | The quote body — the words the highlight sweeps across. | | author | string | Optional attribution. Rendered as its own static element, never swept. |

QuoteOptions (quote mode)

All optional; defaults keep the reference look. openQuote, closeQuote, and authorSeparator may each be set to "" (empty string) to render none.

| Option | Type | Default | Description | | ----------------- | --------- | --------------- | ------------------------------------------------------------------ | | authorPauseMs | number | 1600 | Extra dwell on the author after a quote's sweep finishes, ms. | | loop | boolean | true | Auto-cycle and loop back to the first quote after the last. | | loopPauseMs | number | authorPauseMs | Dwell before looping from the last quote back to the first, ms. | | openQuote | string | "“" | Opening quotation mark wrapped around the quote text. "" = none. | | closeQuote | string | "”" | Closing quotation mark wrapped around the quote text. "" = none. | | authorSeparator | string | "— " | Separator prepended to the author attribution. "" = none. |

CaptionTheme tokens (used by appearance and each preset)

Every token maps to a --cap-* CSS custom property. Omit a token and the CSS default (below) applies. Values are all optional.

| Token | Type | CSS variable | Default | | ---------------------- | ------------------------------------------------------ | -------------------------------------------------- | --------------------------------------------------- | | fontFamily | string | --cap-font-family | 'Barlow Condensed', 'Arial Narrow', sans-serif | | fontWeight | number \| string | --cap-font-weight | 900 | | fontSizePx | number | --cap-font-size | 30px | | lineHeight | number | --cap-line-height | 1.25 | | letterSpacingEm | number | --cap-letter-spacing | 0.02em | | textTransform | "uppercase" \| "none" \| "lowercase" \| "capitalize" | --cap-text-transform | uppercase | | textColor | string | --cap-text-color | #ffffff | | strokeColor | string | --cap-stroke-color | #000000 | | strokeWidthPx | number | --cap-stroke-width | 3px | | shadowColor | string | --cap-shadow-color | rgba(0,0,0,0.55) | | shadowBlurPx | number | --cap-shadow-blur | 5px | | shadowDistancePx | number | --cap-shadow-offset-x / --cap-shadow-offset-y* | 0px / 4px | | shadowAngleDeg | number | resolves into --cap-shadow-offset-x/y* | — | | highlightColor | string | --cap-highlight-color | linear-gradient(180deg, #E62E64 0%, #C4124C 100%) | | highlightTextColor | string | --cap-highlight-text-color | #ffffff | | highlightPaddingXPx | number | --cap-highlight-padding-x | 8px | | highlightPaddingYPx | number | --cap-highlight-padding-y | 3px | | highlightRadiusPx | number | --cap-highlight-radius | 8px | | highlightOpacity | number | --cap-highlight-opacity | 1 | | backgroundColor | string | --cap-background-color | transparent | | backgroundPaddingXPx | number | --cap-background-padding-x | 0px | | backgroundPaddingYPx | number | --cap-background-padding-y | 0px | | backgroundRadiusPx | number | --cap-background-radius | 0px | | popScale | number | --cap-pop-scale | 1 | | popDurationMs | number | --cap-pop-duration | 150ms | | popEasing | string | --cap-pop-easing | ease-out | | wordGapEm | number | --cap-word-gap | 0.62em |

* The drop shadow is expressed as a distance + angle in tokens (like CapCut) and resolved into x/y offsets. When you set only shadowDistancePx, it maps to --cap-shadow-offset-y. Set both shadowDistancePx and shadowAngleDeg for a directional offset. The --cap-shadow-offset-x / --cap-shadow-offset-y variables can also be set directly via CSS.

The stylesheet also exposes --cap-scroll-duration (150ms) and --cap-scroll-easing (ease-out) for the page fade-in — reused for the quote-to-quote crossfade in quote mode. These have no appearance token — set them via CSS if you want to retune the paging/crossfade transition.

--cap-author-* tokens (author attribution, quote mode)

The quote author is themed separately from the quote body. Every CaptionTheme token above has a parallel --cap-author-* custom property, written by the authorAppearance prop (which shares the CaptionTheme shape). The naming mirrors the base set one-to-one — swap the --cap- prefix for --cap-author-:

| appearance token / --cap-* | Author equivalent (--cap-author-*) | | ----------------------------------------------------- | --------------------------------------------------------- | | --cap-font-family | --cap-author-font-family | | --cap-font-weight | --cap-author-font-weight | | --cap-font-size | --cap-author-font-size | | --cap-line-height | --cap-author-line-height | | --cap-text-color | --cap-author-text-color | | --cap-stroke-color / --cap-stroke-width | --cap-author-stroke-color / --cap-author-stroke-width | | --cap-shadow-* | --cap-author-shadow-* | | …every other --cap-* typography/stroke/shadow token | …its --cap-author-* counterpart |

The author carries no per-word highlight box (the highlight* tokens don't apply — the author is never swept). By default it renders a touch smaller and lighter than the quote body — --cap-author-font-size is 19px, --cap-author-font-weight is 600, and --cap-author-text-transform is none — while colour, stroke, and shadow inherit the corresponding --cap-* quote value unless overridden. Set any of these via authorAppearance (typed) or raw --cap-author-* CSS on className.

Caption CLI

The capicola-caption CLI generates *.caption.json files (matching CaptionData) that spread straight into <Capicola {...caption} />.

# From existing audio — word-level transcription via WhisperX
npx capicola-caption --from-audio narration.mp3 --name my-caption --out ./assets

# TTS with word marks — Amazon Polly
npx capicola-caption --tts "Hello world, this is a caption." \
  --provider polly --voice Joanna --name my-caption --out ./assets

# TTS with word marks — ElevenLabs
npx capicola-caption --tts "Hello world, this is a caption." \
  --provider elevenlabs --voice 21m00Tcm4TlvDq8ikWAM --name my-caption --out ./assets
  • --from-audio transcribes an audio file at the word level with WhisperX (pip install whisperx; model overridable via WHISPERX_MODEL).
  • --tts synthesizes speech and emits both the audio and word timings in one step, via Amazon Polly (needs the AWS CLI + credentials) or ElevenLabs (needs ELEVENLABS_API_KEY).

The CLI uses only Node.js built-ins — no npm install to run it. External tools/keys are checked at runtime, with clear guidance and a non-zero exit when something is missing. See scripts/README.md for the full option reference and output format.

Fonts

The default font is Barlow Condensed (first in the --cap-font-family stack, weight 900). Load it for the reference look — it isn't bundled:

<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
  href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;600;700;900&display=swap"
  rel="stylesheet"
/>
# or via @fontsource
pnpm add @fontsource/barlow-condensed

Any font works — set --cap-font-family or appearance.fontFamily (the color, bubble, and plain presets use Inter). Capicola waits for the requested webfont to load before revealing the caption, so there's no flash of the fallback face.

SSR & Next.js

Capicola is client-side: it measures the DOM and, for anchored placement, appends into document.body, so it only runs on the client (the React wrapper creates the engine in an effect, after hydration). In the Next.js App Router, import capicola/react from a Client Component (add "use client" at the top of that file). No other configuration is needed. The createCapicola engine and the <capicola-caption> web component are likewise client-only — call/mount them in the browser.

Browser support

Capicola targets modern evergreen browsers. The outline is rendered with -webkit-text-stroke + paint-order: stroke fill (Chromium, Safari, and Firefox all support these). Anchored positioning appends into document.body with position: fixed. Motion respects prefers-reduced-motion (the pop and page fade are disabled).

Bundle size & tree-shaking

The core engine is small (well under 12 kB gzipped) and ships ESM + CJS with "sideEffects" set so bundlers tree-shake freely; the stylesheet is a separate capicola/styles.css import. The core has zero runtime dependencies; the React adapter's only (optional) peer is react itself.

Stability

Capicola is pre-1.0 (0.x) and follows semver: while 0.x, minor versions may contain breaking changes and patch versions are safe. The prop names and --cap-* variables in this README are the intended stable surface; changes are called out in the CHANGELOG.

Contributing

Issues and PRs are welcome. See CONTRIBUTING.md to get started.

Links

License

MIT © Michael Gamble