Spool
A container that changes shape to fit whatever it is showing, on a spring that carries its velocity through an interruption.
Installation
npx shadcn@latest add @loomui/spoolUsage
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
| Part | Description |
|---|---|
Spool | The shape. Owns the morph and the spring. |
SpoolItem | One state's contents. Sized by what is in it. |
Props
Spool takes everything a div takes, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | Which state is showing. |
spring | SpringOptions | {duration: .42, bounce: .18} | How the shape travels. |
radius | number | 999 | Corner radius, kept circular. |
fade | number | 140 | Milliseconds 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.