If you have ever wanted that "letters cut from paper and dropped onto a page" look for a hero title or intro screen, you don't need GSAP or a heavy animation library. You can build it with plain CSS transforms and about 30 lines of JavaScript.
In this tutorial we'll build a simple paper-cutout drop-in text effect: each letter starts rotated and offset like a scattered paper cutout, then settles into place with a slight bounce.
What we are building
Each character in a string gets wrapped in its own <span>, given a random rotation and offset via CSS custom properties, then animated into its resting position with a staggered delay. The result looks hand-assembled, like stop-motion paper animation.
Step 1: The HTML
<h1 id="paper-text" data-text="Hello Paper World"></h1>
We store the text in a data-text attribute so JavaScript can read it and rebuild it as individual spans.
Step 2: Splitting the text into letters
const el = document.getElementById('paper-text');
const text = el.dataset.text;
el.innerHTML = text
.split('')
.map((char, i) => {
const rotate = (Math.random() * 40 - 20).toFixed(1);
const x = (Math.random() * 60 - 30).toFixed(1);
const y = (Math.random() * 60 - 30).toFixed(1);
return `<span class="paper-char" style="--r:${rotate}deg; --x:${x}px; --y:${y}px; --delay:${i * 0.05}s">${char === ' ' ? ' ' : char}</span>`;
})
.join('');
Each letter gets its own random rotation and offset, stored as CSS custom properties. The delay is based on the letter's position, so the animation reads left to right instead of all letters snapping in at once.
Step 3: The CSS
.paper-char {
display: inline-block;
transform: translate(var(--x), var(--y)) rotate(var(--r));
opacity: 0;
animation: settle 0.6s cubic-bezier(0.17, 0.89, 0.32, 1.28) forwards;
animation-delay: var(--delay);
}
@keyframes settle {
to {
transform: translate(0, 0) rotate(0deg);
opacity: 1;
}
}
The cubic-bezier values give a slight overshoot at the end, so each letter looks like it's settling onto the page rather than just fading in. That overshoot is what sells the "paper" feel.
Step 4: Trigger it on scroll (optional)
If the heading is below the fold, you probably want the animation to play when it enters the viewport, not immediately on page load:
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate');
observer.unobserve(entry.target);
}
});
});
observer.observe(el);
Then move the animation property under .paper-char.animate instead of applying it directly, so it only fires once the class is added.
A few tweaks worth trying
- Lower the rotation range (
20degto8deg) for a subtler, more professional effect - Add a faint
box-shadowto each span for a real "paper piece" look - Swap
translatefor larger values if you want a more dramatic scatter-to-order entrance
Why build this from scratch
Doing it this way keeps your bundle size small and gives you full control over timing, easing, and how it reacts to scroll. That said, if you're prototyping quickly, testing a concept for a client, or just don't want to hand-tune keyframes for every project, I built PaperAnimators — a free, browser-based tool that generates this kind of paper-style text and image animation without writing any code. No login, nothing uploaded to a server, everything runs client-side. Worth a look if you want to skip straight to the export.
Wrapping up
That's the whole effect: split the text, randomize each letter's starting transform, animate it back to rest with a bit of overshoot, and optionally gate it behind an IntersectionObserver. It's a good pattern to keep in your toolkit for hero sections, loading screens, or anywhere you want text to feel like it's physically assembling itself.
What other CSS-only text effects would you want a breakdown of? Let me know in the comments.
Top comments (0)