Marching ants and scrambling text
William Martinsson · May 17, 2026 · ~10 min build
Two effects do almost all the work on the add-to-cart button on this site. The first is a dashed border that crawls around the edge while the system is busy. The second is a label that scrambles through random characters before resolving into the next word. Neither is technically new. Together they handle every state transition the button needs, without anything sliding into the page from somewhere else.
What follows is the two recipes, step by step, in the order I'd build them if I were starting from scratch. The full implementation in this repo is in src/components/AddToCartButton.tsx.
Marching ants
The dashed line that crawls around a Photoshop selection is one of the oldest motion patterns in computer interfaces. It already means something: this thing is active, and something is happening to it. Borrowing it for a loading state means the user does not have to learn anything new. Most of the work is done before we write a line of code.
Underneath the effect is one SVG rect, one CSS keyframe, and a handful of conditionals. We'll build it in three steps.
Step 1 — A dashed rect
Start with the bare element. An SVG rectangle, transparent fill, dashed stroke, positioned absolutely so it covers the button.
<svg className="absolute inset-0 w-full h-full pointer-events-none">
<rect
x="0.5"
y="0.5"
width="calc(100% - 1px)"
height="calc(100% - 1px)"
fill="none"
stroke="#F7F7F7"
strokeWidth="1"
strokeDasharray="4 3"
/>
</svg>The 0.5px inset and calc(100% - 1px) sizing prevent the stroke from being clipped at the SVG edges. The 4 3 dash array sets the rhythm — four pixels of line, three pixels of gap. Different values produce different feels.
Step 2 — Make it march
Animate stroke-dashoffset. Shifting the offset slides the dash pattern along the path of the stroke, so the dashes appear to crawl.
@keyframes march {
to { stroke-dashoffset: -28; }
}<rect
/* ...same as before */
style={{ animation: "march 1.5s linear infinite" }}
/>The -28 is the length of one full dash cycle. For a 4 3 array (total 7px per repeat), -28 is exactly four cycles, which produces a clean loop with no visible jump. Pick any multiple of the dash total.
Step 3 — React to state
The button has three states — idle, loading, success — and a hover boolean. The march responds to all of them. On idle, no animation. On hover, slow march. On loading, fast march (three times faster). On success, the dashes collapse into a solid line.
const dashArray = state === "success" ? "0 0" : "4 3";
const shouldAnimate = state === "loading" || hovered;
const duration = state === "loading" ? "0.5s" : "1.5s";
const strokeColor =
state === "success" ? "#F7F7F7" :
hovered ? "#F7F7F7" :
"#6b7280";
return (
<svg className="absolute inset-0 w-full h-full pointer-events-none">
<style>{`@keyframes march { to { stroke-dashoffset: -28; } }`}</style>
<rect
x="0.5"
y="0.5"
width="calc(100% - 1px)"
height="calc(100% - 1px)"
fill="none"
stroke={strokeColor}
strokeWidth="1"
strokeDasharray={dashArray}
style={{
animation: shouldAnimate
? `march ${duration} linear infinite`
: "none",
transition: "stroke 0.5s, stroke-dasharray 0.5s",
}}
/>
</svg>
);The CSS transition on stroke and strokeDasharray is the small move that pulls the success state together. Without it, the border snaps from dashed to solid. With it, the dashes glide into a single line.
Scrambling text
The label scrambles through random glyphs and resolves into the new word, left to right. It is the visual move from any 90s movie where someone is “decrypting” something. Lifted into a UI, it does one specific job: it draws the eye through the transition, so by the time the new label has landed the user has already noticed something changed.
Step 1 — The character pool
Any legible set of characters works. I use a mix of letters, digits, and symbols.
const GLYPHS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
"abcdefghijklmnopqrstuvwxyz" +
"0123456789!@#$%&*+=-~<>[]{}|/\\";If you are not using a monospace font, avoid characters whose width varies a lot (i, l, 1, .) — they cause the label to jitter horizontally mid-scramble. Monospace dodges this entirely, which is why the button uses font-mono for the label.
Step 2 — The per-character lock
The trick that makes the effect read as “decrypting” rather than “random noise” is the lock. Characters from the left freeze into their final value first; characters on the right keep scrambling until their turn comes. The whole thing is one hook that returns a display string for a given target.
function useScrambleText(target: string, trigger: number, speed = 30) {
const [display, setDisplay] = useState(target);
const frameRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
let iteration = 0;
const totalFrames = target.length * 3;
const tick = () => {
setDisplay(
target
.split("")
.map((char, i) => {
if (char === " ") return " "; // skip spaces
if (i < iteration / 3) return target[i]; // locked
return GLYPHS[Math.floor(Math.random() * GLYPHS.length)];
})
.join("")
);
iteration++;
if (iteration <= totalFrames) {
frameRef.current = setTimeout(tick, speed);
}
};
tick();
return () => {
if (frameRef.current) clearTimeout(frameRef.current);
};
}, [target, trigger, speed]);
return display;
}The iteration / 3 ratio controls how quickly the lock progresses. With a 30ms speed, each character takes about 90ms to lock — a 10-character label resolves in roughly 900ms. Adjust speed and ratio to taste. Under 500ms feels snappy. Closer to a full second feels deliberate.
Step 3 — Re-running on state change
The hook re-runs whenever target changes, which covers the obvious case (the button's label switches from Add to bag to Adding). To re-trigger it without a target change — say, on hover — pass an extra trigger counter and bump it from the parent.
const [trigger, setTrigger] = useState(0);
// usage
const display = useScrambleText(label, trigger);
// re-fire externally (on hover, focus, anything)
setTrigger((t) => t + 1);In the button, the scramble also fires on hover, before any click. That tiny pre-click acknowledgement is half of what makes the button feel like it is paying attention.
In the button
The two effects sit on top of each other inside the same element. The marching border carries the system status — am I busy, am I done. The scrambled label carries the system message — what just changed. Both live in the same 260×48 pixel slot the user's cursor is already pointed at. Nothing pops up somewhere else.
Combined, they look like this — same demo as the top of the page, with the recipes now visible underneath.
Both effects are short. A few dozen lines each. The reason they punch above their weight is not the implementation — it is that they borrow meanings the user already has. Marching ants from Photoshop. Scrambling glyphs from terminals and movie tropes. The work was not in inventing them. The work was deciding that an ordinary button could carry them, and then putting them in the place the user was already looking.
Take the code, change the dash array, change the glyph set, change the speeds. Both effects survive a lot of tuning before they stop working. Have fun.