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

doodad

v0.0.1

Published

Small, playful React components for micro text animations on subtext and labels.

Readme

doodad

Nobody animates text. Modals get transitions, drawers get springs, and page changes get whole libraries. The label above the input sits perfectly still.

doodad is a small set of React components for that text. Each one does a single thing to a word or a label. Each one is opinionated: the font, the timing and the easing curve are baked in, and what you change is the color, the speed and how far it travels.

Live at doodad.ryancuff.com. Built in public, one component at a time.

npm install doodad
import { JiggleWord } from "doodad";

<p>
  Each component is <JiggleWord>opinionated</JiggleWord>.
</p>;

That is the whole setup. The stylesheet is a side-effect import inside the package entry, so installing it and importing a component is enough. Nothing to add to your global CSS and nothing to remember.

If you would rather control where it lands in your cascade, it is also exposed on its own at doodad/styles.css. Importing it twice is harmless.

react and react-dom 18 or 19 are peer dependencies. clsx and tailwind-merge are the only runtime dependencies. There is no animation library in here and no CSS-in-JS runtime.

The components

| | What it does | Color | | --- | --- | --- | | JiggleWord | Letters swell one after another | Pink #e02271 | | FlipText | Letters turn a full circle, in sequence | Teal #168666 | | RainbowText | Colors drift through the letterforms | An oklch sweep | | SquiggleUnderline | A wavy SVG rule under a word | Blue #2563eb |

Each component ships its own color, so a page using several does not read as one hue repeated. All three solids clear AA on white, which matters because these sit inline in body copy rather than as large text. Every one of them is a variable or a text-* class away from being something else.

Shared props

JiggleWord, FlipText and RainbowText all take these.

| Prop | Type | Default | | | --- | --- | --- | --- | | speed | number | 1 | Pace multiplier. 2 is twice as fast, 0.5 is half | | trigger | "hover" \| "loop" | "hover" | loop runs on its own. RainbowText defaults to loop | | color | string | per component | Any CSS color | | className | string | | Merged with tailwind-merge | | style | CSSProperties | | Accepts CSS custom properties |

speed scales every timing together, so the rhythm holds at any pace.

JiggleWord

Letters swell one after another, and the wave walks the word.

<JiggleWord speed={1.5} height="0.2em" color="#2563eb">
  opinionated
</JiggleWord>

children must be a string, because it gets split per character. height is how far a letter rises, and a number is treated as px. Keep it in em if you want it to track the font size.

| Variable | Default | | --- | --- | | --doodad-jiggle-color | #e02271 | | --doodad-jiggle-weight | 900 | | --doodad-jiggle-font | inherit |

The emphasis is carried by weight and color. The font family is inherited from whatever surrounds the word, so it matches your body copy out of the box. Set --doodad-jiggle-font if you want a display face instead.

A variable font is worth having here, because 900 will otherwise be synthesized from the nearest real weight and the letterforms get slightly smeared. If your body font stops at 700, set --doodad-jiggle-weight to a weight it ships.

Why it is a timer and not one animation

There are two timings and they are deliberately different. One letter takes 560ms to rise and come back. The wave moves to the next letter after 190ms. The rise outlasts the advance, so three or four letters are in the air at once and it reads as one swell instead of a row of separate jumps. Tying them together, so that only one letter ever moves, is what makes it feel jumpy.

@keyframes percentages are static, so a single looping animation cannot hold the rise at a fixed duration while the cycle grows with the word. It either stretches the rise or changes the overlap. So the head of the wave is driven by a timer, and each letter's move is still a CSS animation on the compositor.

will-change: transform goes on the whole word when you enter it, not on each letter as the wave arrives. Declaring it per letter makes the compositor build and throw away a layer for every character, and that churn is the stutter. The layers are released when the pointer leaves.

FlipText

Each letter turns a full circle on its own axis, in sequence.

<FlipText speed={0.6}>public</FlipText>

| Variable | Default | | --- | --- | | --doodad-flip-color | #168666 | | --doodad-flip-weight | 900 | | --doodad-depth | 8em |

The perspective sits inside the transform rather than on the parent element. A parent perspective is shared by every letter, so they would all tumble toward one vanishing point in the middle of the word and the ones at the ends would lean. Per letter, each one turns about its own center. --doodad-depth is in em, so the depth of the tumble tracks the type size instead of looking flat at 32px and violent at 12px.

The easing is ease-in-out-cubic. A full turn wants to leave and arrive slowly and carry its speed through the middle, which is where the letter is edge-on and there is least to look at.

FlipText and JiggleWord share their engine. Both render the same markup and run the same wave, and the difference between them is one @keyframes block and two lines of CSS keyed on the parent class. A new per-letter effect costs about that much.

RainbowText

Colors drift through the letterforms. This one loops by default.

<RainbowText speed={3}>text</RainbowText>

| Variable | Default | | --- | --- | | --doodad-rainbow | a conic gradient in oklch | | --doodad-rainbow-weight | 900 |

The stops are in oklch at a near-constant lightness, so no hue lands darker or muddier than its neighbors the way an hsl rainbow does. Blue is the usual offender: it reads heavier than the yellow beside it and the word appears to pulse as it drifts. The last stop repeats the first, so the loop has no seam.

Three details that are easy to get wrong:

The weight is declared on the base class rather than on the active state. Raising it when the effect turns on would resize the word and reflow the line under the pointer.

The whole thing sits inside an @supports query for background-clip: text. Without clip support, a transparent color does not leave the text unstyled, it leaves it invisible.

The reveal is a color transition, not a fade of the gradient. CSS cannot interpolate background-image, so the gradient is painted and clipped to the glyphs at all times, and what changes is the text color: opaque hides the gradient behind the letterforms, transparent lets it through. 150ms with the default ease, which is the right answer for a hover and color change.

This one repaints rather than compositing, which is the price of gradient text. The painted area is a word, so the price is a word.

SquiggleUnderline

A wavy SVG rule that scales with the type it sits under.

<SquiggleUnderline>
  <a href="https://ryancuff.com">ryancuff.com</a>
</SquiggleUnderline>

Takes children, className and style. No animation on this one yet.

| Variable | Default | | --- | --- | | --doodad-squiggle-color | #2563eb | | --doodad-squiggle-gap | 0.01em |

The wavelength, the amplitude and the stroke width are all multiples of the measured font size, so the squiggle scales with the type instead of needing a number per use. The width comes from a ResizeObserver. The half-cycle count is rounded so the wave lands exactly on the word rather than getting clipped mid-crest, which means the wavelength flexes by a few percent from word to word.

The root sets line-height: 1. Without it the box inherits the paragraph's line height, its bottom edge sits well below the descenders, and the squiggle hangs off the word. The shared baseline is unaffected, because an inline-block aligns on its last line box, which is still the text.

Overriding styles

Each component's color, font and weight rules live inside :where(), which is specificity 0,0,0. A single class of your own beats them, so overriding never depends on which stylesheet your bundler happened to emit last.

<JiggleWord className="text-blue-600">fun</JiggleWord>
<JiggleWord color="#168666">fun</JiggleWord>
<JiggleWord style={{ "--doodad-jiggle-color": "#168666" }}>fun</JiggleWord>

className runs through tailwind-merge, so a text-* utility replaces the default color rather than landing next to it and losing on cascade order.

The boring parts

Nothing shifts the layout when it moves. Decorations are positioned out of flow, and no component changes its font weight between states, so a pass cannot change the width of the word.

Long words do not break in half. Every letter is an inline-block, and a browser may break a line between any two inline-level boxes, so each word is wrapped in a nowrap box and the spaces are the only break opportunities left. A single word longer than its container will overflow rather than split, the same as any unbreakable word.

Touch works. A touch device fires pointerenter on tap and then never fires pointerleave until you touch something else, so a naive hover handler leaves the word running forever. Instead a mouse gets hover, and touch and pen get a tap that plays exactly one pass. Repeat taps during a pass are ignored rather than restarting it, because restarting looks broken on anything that travels: the wave jumps back to the first letter on every tap, so someone tapping quickly only ever sees the first letter move.

Motion respects prefers-reduced-motion. The looping stops. The color stays, because that is the thing telling you the word is special.

Screen readers get the word once. A split word would otherwise be announced a letter at a time, so the pieces are aria-hidden and a visually hidden copy carries the text.

Transforms and opacity only. No animating width, height, top or left, and no transition: all.

One gap worth naming: these are spans, not buttons, so a keyboard user cannot trigger the effect. The text is fully readable without it and the color still marks the word, so nothing is lost but the flourish. Making them focusable would put a tab stop in the middle of a sentence, which is worse.

Easing tokens

The curves the defaults are picked from are exported, in case you want to match them elsewhere.

import { easeOut, easeInOut, easeSpring } from "doodad";

Entering or exiting the screen wants easeOut. Something already on screen that moves and comes back wants easeInOut. Hover and color want plain ease. ease-in is not in there on purpose, because its slow start delays the feedback and reads as sluggish.

Development

npm install
npm run dev        # the playground site in /app
npm run build      # tsup, to dist: ESM, CJS, types and the stylesheet
npm run check      # lint, typecheck and build

The repo root is the publishable package and files is limited to dist. The Next.js app in /app is a local playground and is never published.

src/
├── index.ts
├── types.ts
├── components/
│   ├── flip-text.tsx
│   ├── jiggle-word.tsx
│   ├── letter-wave.tsx       # shared markup for the per-letter effects
│   ├── rainbow-text.tsx
│   └── squiggle-underline.tsx
├── lib/
│   ├── cn.ts                 # clsx and tailwind-merge
│   ├── easing.ts
│   ├── reduced-motion.ts
│   ├── split.ts              # grapheme-safe splitting
│   ├── use-letter-wave.ts    # walks the head of the wave along a word
│   └── use-play-mode.ts      # hover on mouse, tap once on touch
└── styles/
    └── doodad.css

License

MIT, Ryan Cuff.