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

quadratic-vote

v3.0.6

Published

A React component library for implementing interactive quadratic voting systems with smooth animations. Express voting intensity with animated credit allocation.

Readme

license npm latest package npm downloads bundle size storybook

React components for quadratic voting. Respondents spend credits to buy votes, and each extra vote on the same question costs more than the last, so a strong opinion has to be paid for out of a fixed budget.

| Votes on one question | Credits spent | | --------------------- | ------------- | | 1 | 1 | | 2 | 4 | | 3 | 9 | | 5 | 25 | | 10 | 100 |

The cost is vote². Negative votes cost the same as positive ones.

  • No runtime dependencies. React and React DOM are peer dependencies.
  • TypeScript types included.
  • Colours, sizes and layout are props.
  • Credits animate between the pool and the per-question indicators.

Installation

npm install quadratic-vote

yarn add quadratic-vote and pnpm add quadratic-vote work the same way.

Quick start

Everything hangs off one provider. It owns the votes, enforces the budget, and drives the animations.

import QuadraticVote, { Question, useQuadraticVote } from 'quadratic-vote'

const questions: Question[] = [
  { id: 'features', question: 'Should we implement feature X?', vote: 0 },
  { id: 'performance', question: 'Should we prioritize performance?', vote: 0 },
]

function Ballot() {
  const { questions, vote, reset } = useQuadraticVote()

  return (
    <div style={{ display: 'flex', gap: '2rem' }}>
      <QuadraticVote.Pool creditColor='#D1D5DB' circleColor='#3B82F6' />

      {questions.map((q) => (
        <div key={q.id}>
          <p>{q.question}</p>
          <QuadraticVote.Diamond
            id={q.id}
            neutralColor='#9CA3AF'
            positiveColor='#22C55E'
            negativeColor='#EF4444'
          />
          <button onClick={() => vote(q.id, 1)} disabled={q.isDisabledUp}>
            Vote up
          </button>
          <button onClick={() => vote(q.id, -1)} disabled={q.isDisabledDown}>
            Vote down
          </button>
        </div>
      ))}

      <button onClick={reset}>Reset</button>
    </div>
  )
}

export default function App() {
  return (
    <QuadraticVote.Provider credits={100} questions={questions}>
      <Ballot />
    </QuadraticVote.Provider>
  )
}

Every component is also a named export, if you would rather not use the namespace:

import { QuadraticVoteProvider, Pool, Diamond, useQuadraticVote } from 'quadratic-vote'

API reference

<QuadraticVote.Provider>

Wraps your voting interface and holds the state.

| Prop | Type | Required | Description | | ------------- | ------------- | -------- | -------------------------------------------- | | credits | number | yes | Total voting credits. Must be between 4 and 225 | | questions | Question[] | yes | The questions to vote on | | children | ReactNode | yes | Your voting interface | | returnOrder | ReturnOrder | | Refill order, default 'first-out-last-in' |

The provider seeds its state from questions once, when it mounts. To change the ballot afterwards, remount it with a key. credits is read live, so raising or lowering the budget takes effect without a remount.

returnOrder

The order pool circles refill in when credits come back from a diamond.

  • 'first-out-last-in' (default) — the circle that emptied first is the last to refill, so credits retrace their steps. The pool behaves like a stack.
  • 'first-out-first-in' — the circle that emptied first refills first, so the block refills in the direction it drained.
<QuadraticVote.Provider credits={100} questions={questions} returnOrder='first-out-first-in'>
  {/* ... */}
</QuadraticVote.Provider>

Showing what a vote costs

The price of the next vote is the thing respondents cannot see. At 3 votes the next one costs 7, at 0 votes it costs 1, and the button looks the same either way.

costOf(id, delta) returns the price without casting the vote, so a control can show what it will charge:

const { costOf, vote } = useQuadraticVote()

// both controls priced at once; a negative cost is a refund
{[-1, 1].map((delta) => {
  const { cost, affordable } = costOf(q.id, delta)
  return (
    <button key={delta} disabled={!affordable} onClick={() => vote(q.id, delta)}>
      {delta === 1 ? '+' : '−'} {cost > 0 ? `−${cost}` : `+${-cost}`}
    </button>
  )
})}

previewVote(id, delta) does the same calculation and also stores it in preview, which makes <Pool> paint the credits the vote would move in previewColor — the next free ones for a spend, the last spent ones for a refund. Use it for hover; clear it with clearPreview. Casting a vote clears it for you.

const { previewVote, clearPreview, preview } = useQuadraticVote()

<button
  onMouseEnter={() => previewVote(q.id, 1)}
  onMouseLeave={clearPreview}
  onFocus={() => previewVote(q.id, 1)}   // hover alone is unusable by keyboard
  onBlur={clearPreview}
  onClick={() => vote(q.id, 1)}
>
  Vote up {preview ? `(${preview.cost})` : null}
</button>

Both return a VotePreview:

| Field | Type | Description | | ------------ | ------------------ | ------------------------------------------- | | id | string \| number | Question it refers to | | delta | number | Delta being priced, usually +1/-1 | | nextVote | number | Vote the question would land on | | cost | number | Credits consumed, or returned when negative | | affordable | boolean | Whether the budget allows it | | shortfall | number | Credits missing when unaffordable, else 0 |

cost is not 2n + 1. That only holds while a vote moves away from zero. Pressing "up" on a question sitting at -3 moves it to -2 and hands 5 credits back, so cost is negative there. Label your buttons from cost, not from a formula, or half of them will be wrong.

<QuadraticVote.Pool>

The credit budget as a grid of circles, filled as credits are spent.

| Prop | Type | Default | Description | | --------------- | --------- | ----------- | ------------------------------------- | | columns | number | 5 | Columns in the pool grid | | circleRadius | number | 4 | Radius of each credit circle | | circleSpacing | number | 4 | Spacing between circles | | reverse | boolean | false | Reverse the fill direction | | creditColor | string | 'black' | Colour of used credits | | circleColor | string | 'grey' | Colour of available credits | | previewColor | string | '#F59E0B' | Colour of credits a preview would move |

<QuadraticVote.LiquidPool>

A compact alternative to <Pool>, meant for mobile: the budget as a floating drop of water with satellite droplets orbiting it. The droplets shrink and disappear one by one as credits are spent, and the whole thing dries out at zero.

<QuadraticVote.LiquidPool size={140} inkColor='#38BDF8' droplets={7} />

| Prop | Type | Default | Description | | -------------- | -------- | ----------- | ----------------------------------------------------- | | size | number | 120 | Reserved layout footprint (px). Liquid may exceed it. | | inkColor | string | '#ffffff' | Colour of the liquid | | droplets | number | 6 | Satellite droplets at full credits | | spread | number | 0.42 | How far droplets orbit past the blob, × size | | wobble | number | 0.55 | Outline irregularity: 0 round, 1 very lumpy | | driftSeconds | number | 14 | Seconds per drift cycle. Higher is slower | | viscosity | number | 0.16 | How readily liquid fuses, × the blob radius | | settleMs | number | 900 | How slowly the pool drains and refills |

size reserves a square footprint for layout; the liquid drifts outside it.

Droplets range from 15% to 35% of the main blob. Raising viscosity much higher dissolves them, because the goo threshold erases any shape smaller than the blur.

The blobs are plain SVG circles fused by a gooey filter: feGaussianBlur smears neighbouring shapes together and feColorMatrix pushes the blurred alpha back to a hard edge. That works on the alpha channel, so it needs no opaque backdrop, and the filter region extends past the component box, so droplets are never clipped.

A credit flying to or from the pool changes hands at the liquid's edge. Outside it, it is a plain credit in its own colour. Inside, the pool draws it as a shape within the gooey filter, so it stretches toward the blob and merges rather than sitting on top as a crisp circle. A credit coming home is also kept out of the balance until it lands, so a new droplet forms around the arriving credit instead of appearing the moment the vote is cast.

Motion runs on one animation frame loop that writes SVG attributes directly, so a frame costs no React render. It idles while the tab is hidden and freezes for prefers-reduced-motion.

<QuadraticVote.Diamond>

A per-question indicator that grows as votes are allocated.

| Prop | Type | Default | Description | | --------------- | ------------------ | ----------- | ---------------------------------------- | | id | string \| number | required | Must match a question in the Provider | | neutralColor | string | '#A9A9A9' | Colour when no vote is cast | | positiveColor | string | '#00FF00' | Colour for positive votes | | negativeColor | string | '#FF0000' | Colour for negative votes | | circleRadius | number | 4 | Radius of the diamond's circles |

useQuadraticVote()

Reads and writes the vote state. Must be called inside a Provider.

const {
  questions, // Current votes, plus isDisabledUp / isDisabledDown
  credits, // Total budget
  availableCredits, // What is left
  vote, // Cast a vote: (id, delta) => void
  reset, // Set every vote back to zero: () => void

  costOf, // Price a vote without casting it: (id, delta) => VotePreview
  preview, // VotePreview | null — what previewVote last described
  previewVote, // Price a vote and highlight it in Pool: (id, delta) => void
  clearPreview, // Drop the current preview: () => void
} = useQuadraticVote()

vote() takes a delta, not a target: vote(id, 1) adds a vote, vote(id, -1) removes one. A vote that would go over budget is ignored, so drive your buttons off isDisabledUp and isDisabledDown rather than calling and hoping.

See Showing what a vote costs for the last four.

Question

id and vote are the only fields the library uses. Anything else you add is carried through untouched, which is where the question text usually lives.

interface Question {
  id: string | number
  vote: number
  isDisabledUp?: boolean
  isDisabledDown?: boolean
  [key: string]: any
}

Customization

Colours and sizes are props:

<QuadraticVote.Pool
  columns={10}
  circleRadius={6}
  circleSpacing={6}
  creditColor='#EF4444'
  circleColor='#10B981'
/>

<QuadraticVote.Diamond
  id='features'
  neutralColor='#6B7280'
  positiveColor='#3B82F6'
  negativeColor='#F59E0B'
/>

Nothing in the library detects viewport size. Choosing Pool on desktop and LiquidPool on mobile is your app's decision.

Animation

Credits fly from the pool to a diamond when a vote goes up, and back when it goes down. Flights are staggered so several credits move in sequence rather than all at once, they follow the page as it scrolls, and their colour changes in step with the flight.

The Provider mounts the overlay and dispatches the events. The pools and diamonds coordinate through qv:* window events and data-* attributes on the rendered SVG, so rendering more than one pool at a time will make them compete for the same flying credits.

Accessibility

The pools and diamonds are decorative SVG — they show the state, they do not announce it. The controls are yours, so labelling them is too. Give each button an accessible name, drive its disabled state from isDisabledUp and isDisabledDown, and use onFocus alongside onMouseEnter if you show prices on hover, since hover alone is unreachable by keyboard.

Storybook

Browse the Storybook — every component with live prop controls, plus layout recipes and a worked example of building your own interface on the hook.

It is published to Chromatic on every push to main, so the link always shows the latest build.

To run it locally:

npm run storybook             # Dev server on http://localhost:6006
npm run build-storybook       # Static build in storybook-static/
npm run type-check:storybook  # Type-check stories only

Stories live in /stories and import from src directly, so the documentation matches the working tree. They never ship: package.json#files limits the published tarball to dist, and neither the library build nor npm run type-check sees them.

Development

npm run dev       # Demo app
npm run build     # Build the package
npm test          # Run tests
npm run coverage  # Coverage report

Tests use Vitest and React Testing Library.

Examples

Contributing

See the contributing guidelines before opening a pull request.

License

MIT © Civicbase

Links