Spool

A container that changes shape to fit whatever it is showing, on a spring that carries its velocity through an interruption.

Loading preview

Installation

npx shadcn@latest add @loomui/spool

Usage

import { Spool, SpoolItem } from "@/components/ui/spool"
const [state, setState] = React.useState("idle")
 
<Spool value={state}>
  <SpoolItem value="idle">Weaving</SpoolItem>
  <SpoolItem value="saved">Saved</SpoolItem>
</Spool>

Each SpoolItem is one state. Only the one matching value is in the DOM, and the shape follows whatever is inside it. The contents are never poured into a size decided in advance.

The size is never animated

Animating width and height lays the page out on every frame, which is the one thing a shape that changes size must not do.

So the box is set to its new size once, in the same commit that swaps the contents. The difference then replays as a transform. Measure the old box, measure the new one, start at the ratio between them and spring to 1. Layout runs once per change. Every frame in between belongs to the compositor.

set({ sx: seen.width / last.width, sy: seen.height / last.height })
to({ sx: 1, sy: 1 })

The root is only ever laid out at its natural size, so offsetWidth always reports what the contents want, never what is on screen. That is what makes a measurement taken halfway through a morph exact instead of a guess.

It carries width: max-content for the same reason. A parent that can squeeze it, a narrow grid track or a flex row, would leave it measuring a box the contents had already been compressed into, and the shape would come out the size of the squeeze rather than the size of what it holds. If the space is genuinely too tight, let it overflow and clip it from the outside.

Undoing the distortion

A scaled box distorts everything inside it. Two things put it back.

The contents sit in a wrapper on the inverse scale, which holds them at true size the whole way across. And the corner radius is divided per axis.

border-radius: calc(r / sx) / calc(r / sy);

The two values either side of the slash are the horizontal and vertical radii. Divide each by its own axis and a corner being squashed stays a circle instead of going oval. That one line is the difference between this and a stretched picture of a pill.

Why a spring

A curve has a fixed duration and a fixed start. Retarget one halfway through and it begins again from a standstill, which is the moment a moving thing stops looking like it was ever moving.

A spring has neither. It carries velocity, so a state that changes while the shape is still travelling keeps the motion it already had. Click the pill mid morph and watch it never once stop to start again.

<Spool spring={{ duration: 0.42, bounce: 0.18 }} />

duration is roughly how long it settles. bounce is how far it overshoots on the way. 0 arrives and stops. Above 0 passes the target and comes back. Keep it under 0.3 for anything not meant to be playful.

The contents fade against the shape, not a clock

Holding the contents at true size has a catch. While the box is at half size, what is inside it is already full size, and wider than the thing holding it. Left alone, that shows up as words clipped down the middle.

So the shape clips, and the contents fade in against how far along the morph is rather than against a duration.

const gap = Math.max(Math.abs(1 - sx), Math.abs(1 - sy))
const progress = 1 - Math.min(gap / span, 1)

A fixed 180ms would have been right for exactly one pair of sizes. A wider gap takes the spring longer, an interrupted morph takes a different length again, and either way the text turns up before the room does. Progress cannot have that problem. Contents start arriving at 45% and are solid by 85%, whatever the shape is doing and however many times you retargeted it on the way.

Each piece inside the state gets its own beat. The icon lands, then the label, each one about 7% of the morph behind the last. Scale, blur and opacity move together on all of them, because any one of the three on its own reads as a fade while all three read as something arriving.

The stagger is held against progress too, not a clock. Interrupt the morph halfway and you never end up with half the pieces arriving and half already sitting there.

One state is on screen at a time. The old one is gone by 120ms, the pieces of the new one start there and land 45ms apart. Growing, they wait for the room as well, since the contents are still wider than the shape holding them.

That handoff runs on a clock rather than on the shape's progress, and it is the one part of the component that does. A spring covers most of its distance in the first third, so a handoff written in progress fires almost at once and reads as a twitch rather than a handover.

That is also why the fade is written onto the node instead of declared as a keyframe. A running animation outranks an inline style, so only one of them can own opacity, and only one of them knows where the shape is.

The outgoing state blurs as it goes rather than only fading. Fading alone leaves the old words legible right until they vanish, which reads as a cut.

The outgoing state is held just long enough to fade. It sits out of the flow, so it never has a say in the size being measured.

<Spool fade={140} />

Parts

PartDescription
SpoolThe shape. Owns the morph and the spring.
SpoolItemOne state's contents. Sized by what is in it.

Props

Spool takes everything a div takes, plus:

PropTypeDefaultDescription
valuestringrequiredWhich state is showing.
springSpringOptions{duration: .42, bounce: .18}How the shape travels.
radiusnumber999Corner radius, kept circular.
fadenumber140Milliseconds the outgoing state leaves.

Reduced motion

With reduced motion the shape jumps straight to its new size and the contents arrive without the settle. Nothing is animated, and nothing is hidden.