CSS + JS Animation. Framer Motion is cleaner. Wasn't able to get the exit/entry animations to work with @starting-style properly. It should be possible — but short on time.
---
// There are 4 stages of the animation:
// 1. Initial: The button is in its initial state. On first load, must not animate presence. (initial={false} equivalent)
// 2. When the state changes to pending, the initial text slides down and the spinner appears from the top.
// 3. When the state changes to success or error, the spinner disappears to the bottom and the success/error text appears from the top.
// 4. When the state changes to idle, the error / success text slides down and the initial text appears from the top.
//::TODO:: try it with @starting-style at some point. seems promising. @simple-animated-button is a good example for a halfway point.
import { spring } from "motion";
import Spinner from "phosphor-astro/Spinner.astro";
const springTransition = spring(0.2, 0);
---
<button id="spinner-button" class="blue-button">
<span class="idle state active">Send me a login link</span>
<span class="pending state">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
>
<g>
<circle cx="3" cy="12" r="2" fill="currentColor"></circle>
<circle cx="21" cy="12" r="2" fill="currentColor"></circle>
<circle cx="12" cy="21" r="2" fill="currentColor"></circle>
<circle cx="12" cy="3" r="2" fill="currentColor"></circle>
<circle cx="5.64" cy="5.64" r="2" fill="currentColor"></circle>
<circle cx="18.36" cy="18.36" r="2" fill="currentColor"></circle>
<circle cx="5.64" cy="18.36" r="2" fill="currentColor"></circle>
<circle cx="18.36" cy="5.64" r="2" fill="currentColor"></circle>
<animateTransform
attributeName="transform"
dur="1.5s"
repeatCount="indefinite"
type="rotate"
values="0 12 12;360 12 12"></animateTransform>
</g>
</svg>
</span>
<span class="success state">Login link sent!</span>
<span class="error state">Error</span>
</button>
<style define:vars={{ springTransition }}>
span.active {
opacity: 1;
transform: translateY(0);
display: flex;
transition: all 0.3s var(--ease-out-expo);
transition: all var(--springTransition);
}
span:not(.active) {
opacity: 0;
transform: translateY(-25%);
}
span.state {
position: absolute;
inset: 0;
align-items: center;
justify-content: center;
color: white;
text-shadow: 0px 1px 1.5px rgba(0, 0, 0, 0.16);
}
.blue-button {
border-radius: 8px;
font-weight: 500;
font-size: 13px;
height: 32px;
width: 148px;
overflow: hidden;
background: linear-gradient(180deg, #1994ff 0%, #157cff 100%);
box-shadow:
0px 0px 1px 1px rgba(255, 255, 255, 0.08) inset,
0px 1px 1.5px 0px rgba(0, 0, 0, 0.32),
0px 0px 0px 0.5px #1a94ff;
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
cursor: pointer;
}
.blue-button:hover {
background: linear-gradient(180deg, #1994ff 0%, #1585ff 100%);
}
</style>
<script>
type ButtonState = "idle" | "pending" | "success" | "error";
const button = document.getElementById("spinner-button");
if (!button) throw new Error("Button element not found");
let currentState: ButtonState = "idle";
let stateTimeout: ReturnType<typeof setTimeout>;
function onTransitionsEnded(node: Element) {
return Promise.allSettled(
node.getAnimations().map((animation) => animation.finished)
);
}
async function updateButtonState(newState: ButtonState) {
const button = document.getElementById(
"spinner-button"
) as HTMLButtonElement;
const currentActive = button.querySelector(".state.active") as HTMLElement;
if (!currentActive) return;
// Start exit animation by adding exit styles
currentActive.style.opacity = "0";
currentActive.style.transform = "translateY(25%)";
// Wait for exit animation to complete
await onTransitionsEnded(currentActive);
// Remove active class after exit animation
currentActive.classList.remove("active");
// Reset inline styles
currentActive.style.opacity = "";
currentActive.style.transform = "";
// Add active class to new state to trigger enter animation
const stateElement = button.querySelector(`.${newState}`) as HTMLElement;
if (stateElement) {
stateElement.classList.add("active");
}
currentState = newState;
}
function handleStateTransition() {
if (currentState === "idle") {
updateButtonState("pending");
// After 2 seconds, transition to either success or error
stateTimeout = setTimeout(() => {
const nextState: ButtonState =
Math.random() < 0.5 ? "success" : "error";
updateButtonState(nextState);
// After 2 more seconds, return to idle
stateTimeout = setTimeout(() => {
updateButtonState("idle");
}, 2000);
}, 2000);
}
}
button.addEventListener("click", () => {
// Only trigger if we're in idle state
if (currentState === "idle") {
handleStateTransition();
}
});
</script>
User-initiated interactions (e.g., opening a modal)
Enter and exit animations
When you want a responsive and snappy feel
Ease-In-Out
When to Use:
When an element is already on screen and needs to move or morph
For satisfying, smooth animations
Ease-In
When to Use:
Generally avoid due to the slow start, which can make interfaces feel sluggish
Use caution with enter animations
Linear
When to Use:
Generally avoid for interactive elements, as it can feel robotic
Suitable for continuous, non-interactive animations (e.g., loading spinners, marquees)
Ease
When to Use:
Hover effects that transition color, background-color, opacity, etc.
Custom Easings
When to Use:
When you need a specific feel or acceleration not provided by built-in curves
To mimic other platforms or create a unique brand feel
To make animations feel more energetic
Creating Custom Curves
To experiment with different curves and feel
To mimic other platforms or create a unique brand feel
Timing (Duration):
General Guideline: 200-300ms for most animations (sweet spot aligning with human reaction time).
Hover Transitions: 150ms or even less (user is already focused, sensitive to color/opacity changes).
Modal/Popover Enter: 200ms.
Modal/Popover Exit: 150ms.
Large View Changes: Longer durations (e.g., Vercel’s Time Machine at 1 second).
Steep Easing Curves: May require longer durations to avoid feeling too abrupt (e.g. Vaul example using 500ms).
Default for Modals: 200ms with ease-out. Adjust if needed.
Perceived Speed: Shorter is generally better; animations longer than 700ms should be rare.
Exception: loading spinners should never be slow.
Iteration: Take breaks; perception of speed changes with prolonged work.
Purpose:
Primary Goal: Add context and clarity to the user experience.
Self-Check: Can you easily explain the benefit the animation provides?
Examples of Purposeful Animation:
Explaining concepts or processes (e.g., Vercel’s v0 animation).
Indicating state changes (e.g., App Store cards).
When NOT to Animate:
High-frequency interactions (e.g., Raycast’s command menu).
Keyboard interactions (can feel slow and disconnected).
Delight:
Use sparingly and thoughtfully.
Combine expected animations (enter/exit) with a few well-crafted ones for delight.
Prioritize tastefulness and elegance over quantity.
Key Takeaway:
Animation should be purposeful, enhancing understanding and usability. Timing should be carefully considered to balance responsiveness with clarity. Delightful animations should be used judiciously to create a positive, engaging experience without overwhelming the user.