doodad
v0.0.1
Published
Small, playful React components for micro text animations on subtext and labels.
Maintainers
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 doodadimport { 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 buildThe 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.cssLicense
MIT, Ryan Cuff.
