Skip to content

Basone01/react-jq-cloud

Repository files navigation

@basone01/react-jq-cloud

A React + TypeScript word cloud component based on the layout algorithm from jQCloud.

Words are placed on a spiral (elliptic or rectangular) starting from the center outward. Heavier words land closest to the center. Collision detection uses AABB (axis-aligned bounding box) checks so words never overlap. Font sizes are rendered with a two-pass approach — words are first rendered invisibly to measure their real DOM dimensions, then the pure layout algorithm runs, and finally words are re-rendered at their computed positions.

🟢 Live Demo


Table of contents


Installation

npm install @basone01/react-jq-cloud

Peer dependencies (react and react-dom ≥ 17) must already be installed in your project.


Quick start

import { ReactJQCloud } from '@basone01/react-jq-cloud';
import '@basone01/react-jq-cloud/styles.css';

const words = [
  { text: 'React',      weight: 10 },
  { text: 'TypeScript', weight: 8 },
  { text: 'Open Source',weight: 6 },
  { text: 'Vite',       weight: 5 },
  { text: 'npm',        weight: 3 },
];

export default function App() {
  return <ReactJQCloud words={words} width={600} height={400} />;
}

The stylesheet provides the default w1w10 color classes. You can skip it and supply your own colors via the colors prop or per-word color field.


Props

Prop Type Default Description
words Word[] Required. Array of words to render.
width number | string Required. Container width — a pixel number or any CSS value (e.g. "100%"). When a string is passed, the actual pixel width is measured via ResizeObserver.
height number Required. Container height in px.
center { x: number; y: number } { x: width/2, y: height/2 } Starting point of the spiral.
shape 'elliptic' | 'rectangular' 'elliptic' Spiral shape.
fontSizes [number, number] [12, 60] [minPx, maxPx] — font size range mapped linearly to the weight range.
fontFamily string inherited Font family applied to every word.
removeOverflowing boolean true Drop words whose bounding box extends outside the container.
spacing number 0 Extra pixels of padding added around each word's bounding box during collision detection. Increase to add breathing room between words.
wrapAtPercent number Max width as a percentage of the container width. Words wider than this wrap onto multiple lines.
ellipsisAtPercent number Max width as a percentage of the container width. Words wider than this are truncated with .
wrapAtPercentOnLimit number Like wrapAtPercent, but only activates when shrinkToFit reaches its minimum scale (30 %). Use as a last-resort fallback for very dense clouds.
ellipsisAtPercentOnLimit number Like ellipsisAtPercent, but only activates when shrinkToFit reaches its minimum scale (30 %). Use as a last-resort fallback for very dense clouds.
shrinkToFit boolean false Iteratively reduce font scale (down to 30 % of original) until all words fit inside the container. Overrides removeOverflowing.
wordDelay number 0 Milliseconds between each word appearing after layout. Words reveal in weight-descending order (heaviest first). 0 = all words appear at once.
colors string[] 10-element color array indexed by weight class (index 0 = class w1). Overrides CSS classes.
className string Extra class name added to the container <div>.
style React.CSSProperties Inline styles merged onto the container <div>.
onWordClick (word: Word, event: React.MouseEvent) => void Click handler called with the Word object and the native event.
onWordReveal (revealed: number, total: number) => void Called on each step of the wordDelay animation with the current count and total placed words.
afterCloudRender () => void Called once after all words are visible (after the last wordDelay step when used, or immediately after layout otherwise).
renderText (word: Word) => string Override the displayed text for each word. Receives the Word object; the returned string is rendered in place of word.text. Layout measurement still uses word.text.
renderTooltip (word: Word) => React.ReactNode Custom tooltip renderer. Called with the hovered Word; the returned node is rendered in a portal above the word.
tooltipContainer Element document.body DOM element used as the portal target for renderTooltip.

Word shape

interface Word {
  text: string;    // displayed label
  weight: number;  // relative importance — drives font size and color class

  // optional
  html?:      Record<string, string>;  // extra HTML attributes spread onto the word's <span>
  link?:      string | { href: string; target?: string; [key: string]: string | undefined };
  color?:     string;     // per-word inline color (overrides CSS class and colors prop)
  className?: string;     // extra class added to this word's <span>
}

Theming

CSS weight classes

When you import @basone01/react-jq-cloud/styles.css each word receives a class w1w10 (1 = lightest, 10 = heaviest). You can override these classes in your own stylesheet:

/* your-styles.css */
.react-jq-cloud .w10 { color: #e63946; }
.react-jq-cloud .w9  { color: #e63946; }
.react-jq-cloud .w8  { color: #457b9d; }
/* … */
.react-jq-cloud .w1  { color: #a8dadc; }

Inline colors

Pass a 10-element array to the colors prop. Index 0 maps to class w1 (lightest), index 9 to w10 (heaviest):

<ReactJQCloud
  words={words}
  width={600}
  height={400}
  colors={[
    '#ccc', '#bbb', '#aaa', '#999', '#888',
    '#666', '#555', '#333', '#111', '#000',
  ]}
/>

A per-word color field takes precedence over both the colors prop and the CSS class.


Recipes

Clickable words

<ReactJQCloud
  words={words}
  width={600}
  height={400}
  onWordClick={(word, event) => {
    console.log('clicked', word.text);
  }}
/>

Words as links

Pass a URL string or an object with href (and optionally target) to word.link. The word will be wrapped in an <a> tag.

const words = [
  { text: 'React',  weight: 10, link: 'https://react.dev' },
  { text: 'Vite',   weight: 8,  link: { href: 'https://vitejs.dev', target: '_blank' } },
];

<ReactJQCloud words={words} width={600} height={400} />

Animated reveal with wordDelay

Words appear one by one after layout, heaviest first.

<ReactJQCloud
  words={words}
  width={600}
  height={400}
  wordDelay={80}  // 80 ms between each word
/>

Set wordDelay={0} (the default) to skip the animation.

Track reveal progress

onWordReveal fires on every step of the wordDelay animation, letting you drive an external progress indicator.

const [progress, setProgress] = useState({ revealed: 0, total: 0 });

<ReactJQCloud
  words={words}
  width={600}
  height={400}
  wordDelay={100}
  onWordReveal={(revealed, total) => setProgress({ revealed, total })}
/>

<p>{progress.revealed} / {progress.total} words</p>

Fit all words with shrinkToFit

When the canvas is small or the word list is large, some words may be pushed outside the container and dropped. shrinkToFit reduces the overall font scale in steps of 15 % until every word fits — down to a minimum of 30 % of the original fontSizes.

<ReactJQCloud
  words={words}
  width={400}
  height={300}
  shrinkToFit
/>

Note: shrinkToFit internally forces removeOverflowing: true during each layout attempt. The two props are mutually exclusive — when shrinkToFit is enabled, removeOverflowing has no additional effect.

React to render completion

afterCloudRender fires once after all words are visible. Use it to hide a loading spinner, trigger analytics, or start a follow-up animation.

const [ready, setReady] = useState(false);

<>
  {!ready && <Spinner />}
  <ReactJQCloud
    words={words}
    width={600}
    height={400}
    style={{ opacity: ready ? 1 : 0, transition: 'opacity 400ms' }}
    afterCloudRender={() => setReady(true)}
  />
</>

When wordDelay is set, afterCloudRender fires after the last word is revealed, not immediately after layout.

Custom tooltips

Pass renderTooltip to show a tooltip on hover. It receives the Word object and returns any React node. The tooltip is rendered in a document.body portal so it is never clipped by the container's overflow: hidden.

<ReactJQCloud
  words={words}
  width={600}
  height={400}
  renderTooltip={(word) => (
    <div style={{
      background: '#1e1e2e',
      color: '#cdd6f4',
      padding: '6px 10px',
      borderRadius: 6,
      fontSize: 12,
      boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
      marginBottom: 6,
      whiteSpace: 'nowrap',
    }}>
      <strong>{word.text}</strong>
      <span style={{ marginLeft: 8, opacity: 0.6 }}>weight: {word.weight}</span>
    </div>
  )}
/>

The tooltip div is positioned above the hovered word via position: fixed + transform: translate(-50%, -100%). You have full control over its appearance through the returned node.

Wrap / truncate only when shrinkToFit hits its limit

wrapAtPercentOnLimit and ellipsisAtPercentOnLimit are fallback versions of wrapAtPercent / ellipsisAtPercent that only kick in after shrinkToFit has exhausted its minimum scale (30 % of fontSizes). If words still don't fit at that point, the constraint is applied and layout re-runs one final time with the constrained dimensions.

<ReactJQCloud
  words={words}
  width={400}
  height={300}
  shrinkToFit
  ellipsisAtPercentOnLimit={20}  // truncate only if shrink can't fit everything
/>

This lets you keep clean, unconstrained word shapes in most cases while gracefully handling very dense word sets without dropping words.

Wrapping and truncating long words

Use wrapAtPercent to let long words wrap onto multiple lines, or ellipsisAtPercent to clip them with . Both accept a percentage of the container width.

// Wrap words that exceed 25 % of the container width
<ReactJQCloud
  words={words}
  width={600}
  height={400}
  wrapAtPercent={25}
/>

// Truncate words that exceed 25 % of the container width
<ReactJQCloud
  words={words}
  width={600}
  height={400}
  ellipsisAtPercent={25}
/>

The same maxWidth is applied during the invisible pass-1 measurement, so the layout correctly accounts for wrapped or clipped dimensions.

Adding HTML attributes to words

Set word.html to a Record<string, string> to spread arbitrary HTML attributes onto the word's <span>. Useful for data-*, aria-*, or any other attribute.

const words = [
  {
    text: 'React',
    weight: 10,
    html: { 'data-id': 'react', 'aria-label': 'React framework' },
  },
  {
    text: 'TypeScript',
    weight: 9,
    html: { 'data-id': 'typescript', 'data-category': 'language' },
  },
];

<ReactJQCloud words={words} width={600} height={400} />

Fluid / responsive width

Pass any CSS string (e.g. "100%") to width. The component attaches a ResizeObserver to its container and re-runs the layout whenever the measured pixel width changes.

<div style={{ width: '60%' }}>
  <ReactJQCloud
    words={words}
    width="100%"
    height={320}
  />
</div>

The cloud will re-layout automatically when the container is resized.

Async data loading pattern

function MyCloud() {
  const [words, setWords] = useState<Word[]>([]);
  const [ready, setReady] = useState(false);

  useEffect(() => {
    fetchWords().then(data => setWords(data));
  }, []);

  if (words.length === 0) return <Spinner />;

  return (
    <ReactJQCloud
      words={words}
      width={700}
      height={450}
      wordDelay={60}
      afterCloudRender={() => setReady(true)}
      style={{ opacity: ready ? 1 : 0, transition: 'opacity 600ms ease' }}
    />
  );
}

Credits

The layout algorithm is a direct port of jQCloud by Luca Ongaro, originally released under the MIT license.

Key adaptations for React:

  • Two-pass rendering — words are first rendered invisibly at the correct font size so the browser can measure their real pixel dimensions; the pure layout algorithm then runs with those measurements, and words are re-rendered at their computed absolute positions.
  • shrinkToFit — iterative font scaling that is not present in the original library.
  • wordDelay / onWordReveal — staggered reveal animation with progress callbacks.

Contributing

Contributions are welcome — bug reports, feature requests, and pull requests alike.

Development setup

git clone https://github.com/basone01/react-jq-cloud.git
cd react-jq-cloud  # or your fork
npm install
Command Purpose
npm run dev Build in watch mode (tsup)
npm run example Start the Vite dev server for the example app at http://localhost:5173
npm test Run all tests once
npm run test:watch Run tests in interactive watch mode
npm run typecheck TypeScript type check (no emit)
npm run build Production build → dist/

Project layout

src/
  index.ts        public exports
  types.ts        TypeScript interfaces (Word, ReactJQCloudProps)
  layout.ts       pure layout algorithm — no DOM, fully unit-tested
  ReactJQCloud.tsx   React component (two-pass render)
  styles.css      default w1–w10 color classes

test/
  layout.test.ts       unit tests for the layout algorithm
  ReactJQCloud.test.tsx   component rendering tests
  setup.ts             jest-dom setup

example/
  App.tsx         interactive demos (basic, links, long keywords, 50 words,
                  async fetch, word delay, shrink-to-fit)

Guidelines

  • Keep layout.ts pure. It must not import React or touch the DOM. This makes it easy to unit-test and reason about independently of the rendering layer.
  • Write tests for new behaviour. The test suite lives in test/. Run npm test before opening a PR.
  • Match existing code style. TypeScript strict mode is enabled; noUncheckedIndexedAccess is on — index operations need null guards.
  • One concern per PR. Smaller, focused pull requests are easier to review.

Reporting bugs

Please open a GitHub issue with:

  1. A minimal reproduction (ideally a code snippet or a link to a StackBlitz / CodeSandbox).
  2. Expected vs actual behaviour.
  3. Browser and React version.

Releasing (maintainers)

# bump version in package.json, then:
npm run build
npm publish

prepublishOnly runs the build automatically, so dist/ is always up to date before publishing.


License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages