Micro-interactions: Buttons, Inputs, and Feedback
Add polish to your React Shopify theme with micro-interactions. Build satisfying button animations, input focus effects, quantity steppers, and feedback notifications that delight users.
Micro-interactions are the small, often subconscious animations that make interfaces feel alive. A button that depresses when clicked, an input that expands on focus, a notification that slides in—these details separate polished themes from generic ones. In this lesson, we’ll build micro-interactions that make your Shopify theme feel responsive and satisfying.
The Anatomy of a Micro-interaction
Every micro-interaction has four parts:
┌─────────────────────────────────────────────────────────────────────┐│ MICRO-INTERACTION ANATOMY │├─────────────────────────────────────────────────────────────────────┤│ ││ 1. TRIGGER What initiates the interaction ││ └─ User action (click, hover, focus) ││ └─ System event (data loaded, error occurred) ││ ││ 2. RULES What happens when triggered ││ └─ What changes (scale, color, position) ││ └─ How long it takes (duration, easing) ││ ││ 3. FEEDBACK Visual/audio response ││ └─ Immediate acknowledgment ││ └─ Progress indication ││ ││ 4. LOOPS & MODES Ongoing states or repeated behaviors ││ └─ Loading spinner continues until complete ││ └─ Toggle remembers state ││ │└─────────────────────────────────────────────────────────────────────┘Keep micro-interactions subtle. They should enhance, not distract from, the shopping experience.
Button Micro-interactions
Buttons are the primary action elements in any store. Good button interactions communicate clickability, acknowledge input, and indicate state.
The Press Effect
A subtle scale and depth change on click:
import { forwardRef } from 'react';import styles from './Button.module.css';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { variant?: 'primary' | 'secondary' | 'outline'; size?: 'sm' | 'md' | 'lg'; isLoading?: boolean;}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>( ({ variant = 'primary', size = 'md', isLoading, children, ...props }, ref) => { return ( <button ref={ref} className={`${styles.button} ${styles[variant]} ${styles[size]}`} disabled={isLoading || props.disabled} {...props} > <span className={styles.content}> {isLoading && <span className={styles.spinner} />} <span className={isLoading ? styles.hiddenText : ''}>{children}</span> </span> </button> ); });
Button.displayName = 'Button';.button { position: relative; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; border: none; border-radius: 6px; cursor: pointer; transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; /* Prevent text selection on rapid clicks */ user-select: none; -webkit-tap-highlight-color: transparent;}
/* Size variants */.sm { padding: 0.5rem 1rem; font-size: 0.875rem;}
.md { padding: 0.75rem 1.5rem; font-size: 1rem;}
.lg { padding: 1rem 2rem; font-size: 1.125rem;}
/* Color variants */.primary { background: var(--color-primary); color: white; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);}
.primary:hover:not(:disabled) { background: var(--color-primary-dark); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);}
/* The press effect - subtle scale down and shadow reduction */.primary:active:not(:disabled) { transform: scale(0.98); box-shadow: 0 0 0 rgba(0, 0, 0, 0);}
.secondary { background: var(--color-secondary); color: var(--color-text);}
.outline { background: transparent; color: var(--color-primary); border: 2px solid currentColor;}
.outline:hover:not(:disabled) { background: var(--color-primary); color: white;}
/* Disabled state */.button:disabled { opacity: 0.5; cursor: not-allowed; transform: none;}
/* Focus visible for keyboard navigation */.button:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px;}
/* Loading state */.content { display: flex; align-items: center; gap: 0.5rem;}
.spinner { width: 1em; height: 1em; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite;}
@keyframes spin { to { transform: rotate(360deg); }}
.hiddenText { opacity: 0;}
/* Reduced motion */@media (prefers-reduced-motion: reduce) { .button { transition: none; } .spinner { animation: none; border-right-color: currentColor; opacity: 0.5; }}Icon Button with Ripple Effect
For icon buttons, a ripple effect provides satisfying feedback:
import { useRef, useState } from 'react';import styles from './IconButton.module.css';
interface RippleEffect { x: number; y: number; id: number;}
interface IconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { icon: React.ReactNode; label: string; // Required for accessibility}
export function IconButton({ icon, label, onClick, ...props }: IconButtonProps) { const buttonRef = useRef<HTMLButtonElement>(null); const [ripples, setRipples] = useState<RippleEffect[]>([]);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { // Calculate ripple position relative to button const button = buttonRef.current; if (button) { const rect = button.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top;
const newRipple = { x, y, id: Date.now() }; setRipples((prev) => [...prev, newRipple]);
// Remove ripple after animation completes setTimeout(() => { setRipples((prev) => prev.filter((r) => r.id !== newRipple.id)); }, 600); }
onClick?.(e); };
return ( <button ref={buttonRef} className={styles.iconButton} onClick={handleClick} aria-label={label} {...props} > <span className={styles.icon}>{icon}</span>
{/* Ripple effects */} {ripples.map((ripple) => ( <span key={ripple.id} className={styles.ripple} style={{ left: ripple.x, top: ripple.y, }} /> ))} </button> );}.iconButton { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border: none; border-radius: 50%; background: transparent; color: var(--color-text); cursor: pointer; overflow: hidden; transition: background-color 0.15s ease;}
.iconButton:hover { background: var(--color-hover);}
.iconButton:active { background: var(--color-active);}
.icon { position: relative; z-index: 1; display: flex;}
.ripple { position: absolute; width: 100px; height: 100px; margin-left: -50px; margin-top: -50px; border-radius: 50%; background: currentColor; opacity: 0.2; transform: scale(0); animation: rippleExpand 0.6s ease-out; pointer-events: none;}
@keyframes rippleExpand { to { transform: scale(2); opacity: 0; }}
@media (prefers-reduced-motion: reduce) { .ripple { animation: none; }}Input Micro-interactions
Form inputs benefit greatly from micro-interactions. They guide focus, indicate validation state, and provide feedback.
Floating Label Input
Labels that animate from placeholder position to above the input:
import { useState, useId } from 'react';import styles from './FloatingInput.module.css';
interface FloatingInputProps { label: string; value: string; onChange: (value: string) => void; type?: 'text' | 'email' | 'password'; error?: string;}
export function FloatingInput({ label, value, onChange, type = 'text', error,}: FloatingInputProps) { const [isFocused, setIsFocused] = useState(false); const id = useId(); const hasValue = value.length > 0;
return ( <div className={styles.container}> <div className={` ${styles.inputWrapper} ${isFocused ? styles.focused : ''} ${hasValue ? styles.hasValue : ''} ${error ? styles.hasError : ''} `} > <input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} className={styles.input} placeholder=" " // Required for :placeholder-shown aria-invalid={!!error} aria-describedby={error ? `${id}-error` : undefined} /> <label htmlFor={id} className={styles.label}> {label} </label> <div className={styles.border} /> </div>
{error && ( <p id={`${id}-error`} className={styles.error} role="alert"> {error} </p> )} </div> );}.container { margin-bottom: 1rem;}
.inputWrapper { position: relative; background: var(--color-input-bg); border-radius: 8px; overflow: hidden;}
.input { width: 100%; padding: 1.5rem 1rem 0.5rem; border: none; background: transparent; font-size: 1rem; color: var(--color-text); outline: none;}
.label { position: absolute; left: 1rem; top: 50%; transform: translateY(-50%); font-size: 1rem; color: var(--color-text-muted); pointer-events: none; transition: transform 0.2s ease, font-size 0.2s ease, color 0.2s ease; transform-origin: left center;}
/* Label floats up when input has value or is focused */.inputWrapper.focused .label,.inputWrapper.hasValue .label { transform: translateY(-130%); font-size: 0.75rem;}
.inputWrapper.focused .label { color: var(--color-primary);}
/* Animated bottom border */.border { position: absolute; bottom: 0; left: 0; right: 0; height: 2px; background: var(--color-border);}
.border::after { content: ''; position: absolute; bottom: 0; left: 50%; width: 0; height: 2px; background: var(--color-primary); transition: all 0.3s ease;}
.inputWrapper.focused .border::after { left: 0; width: 100%;}
/* Error state */.inputWrapper.hasError .border::after { background: var(--color-error); left: 0; width: 100%;}
.inputWrapper.hasError .label { color: var(--color-error);}
.error { margin-top: 0.25rem; font-size: 0.875rem; color: var(--color-error);}
@media (prefers-reduced-motion: reduce) { .label, .border::after { transition: none; }}Quantity Stepper
A satisfying quantity control for product pages and cart:
import { useState, useRef } from 'react';import styles from './QuantityStepper.module.css';
interface QuantityStepperProps { value: number; onChange: (value: number) => void; min?: number; max?: number;}
export function QuantityStepper({ value, onChange, min = 1, max = 99,}: QuantityStepperProps) { const [isAnimating, setIsAnimating] = useState<'up' | 'down' | null>(null); const inputRef = useRef<HTMLInputElement>(null);
const handleDecrement = () => { if (value > min) { setIsAnimating('down'); onChange(value - 1); setTimeout(() => setIsAnimating(null), 150); } };
const handleIncrement = () => { if (value < max) { setIsAnimating('up'); onChange(value + 1); setTimeout(() => setIsAnimating(null), 150); } };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const newValue = parseInt(e.target.value, 10); if (!isNaN(newValue) && newValue >= min && newValue <= max) { onChange(newValue); } };
return ( <div className={styles.stepper}> <button type="button" className={styles.button} onClick={handleDecrement} disabled={value <= min} aria-label="Decrease quantity" > <MinusIcon /> </button>
<div className={styles.valueWrapper}> <input ref={inputRef} type="number" value={value} onChange={handleInputChange} min={min} max={max} className={` ${styles.input} ${isAnimating === 'up' ? styles.animateUp : ''} ${isAnimating === 'down' ? styles.animateDown : ''} `} aria-label="Quantity" /> </div>
<button type="button" className={styles.button} onClick={handleIncrement} disabled={value >= max} aria-label="Increase quantity" > <PlusIcon /> </button> </div> );}
function MinusIcon() { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <path d="M3 8h10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg> );}
function PlusIcon() { return ( <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> </svg> );}.stepper { display: inline-flex; align-items: center; border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden;}
.button { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border: none; background: transparent; color: var(--color-text); cursor: pointer; transition: background-color 0.15s ease;}
.button:hover:not(:disabled) { background: var(--color-hover);}
.button:active:not(:disabled) { background: var(--color-active);}
.button:disabled { opacity: 0.3; cursor: not-allowed;}
.valueWrapper { position: relative; width: 48px; height: 40px; overflow: hidden;}
.input { width: 100%; height: 100%; border: none; background: transparent; text-align: center; font-size: 1rem; font-weight: 600; color: var(--color-text); -moz-appearance: textfield;}
.input::-webkit-inner-spin-button,.input::-webkit-outer-spin-button { -webkit-appearance: none;}
.input:focus { outline: none; background: var(--color-focus-bg);}
/* Number animation on increment/decrement */.input.animateUp { animation: slideUp 0.15s ease;}
.input.animateDown { animation: slideDown 0.15s ease;}
@keyframes slideUp { 0% { transform: translateY(0); opacity: 1; } 50% { transform: translateY(-50%); opacity: 0; } 51% { transform: translateY(50%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; }}
@keyframes slideDown { 0% { transform: translateY(0); opacity: 1; } 50% { transform: translateY(50%); opacity: 0; } 51% { transform: translateY(-50%); opacity: 0; } 100% { transform: translateY(0); opacity: 1; }}
@media (prefers-reduced-motion: reduce) { .input.animateUp, .input.animateDown { animation: none; }}Toast Notifications
Feedback notifications that slide in to acknowledge actions:
import { useEffect, useState } from 'react';import styles from './Toast.module.css';
type ToastType = 'success' | 'error' | 'info';
interface ToastProps { message: string; type?: ToastType; duration?: number; onClose: () => void;}
export function Toast({ message, type = 'info', duration = 4000, onClose,}: ToastProps) { const [isExiting, setIsExiting] = useState(false);
useEffect(() => { const timer = setTimeout(() => { setIsExiting(true); }, duration - 300); // Start exit animation before removal
const closeTimer = setTimeout(onClose, duration);
return () => { clearTimeout(timer); clearTimeout(closeTimer); }; }, [duration, onClose]);
const handleClose = () => { setIsExiting(true); setTimeout(onClose, 300); };
return ( <div className={` ${styles.toast} ${styles[type]} ${isExiting ? styles.exiting : ''} `} role="alert" aria-live="polite" > <span className={styles.icon}> {type === 'success' && <CheckIcon />} {type === 'error' && <XIcon />} {type === 'info' && <InfoIcon />} </span> <p className={styles.message}>{message}</p> <button className={styles.closeButton} onClick={handleClose} aria-label="Dismiss notification" > <XIcon /> </button> </div> );}.toast { display: flex; align-items: center; gap: 0.75rem; padding: 1rem 1.25rem; background: var(--color-surface); border-radius: 8px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); animation: slideIn 0.3s ease;}
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; }}
.toast.exiting { animation: slideOut 0.3s ease forwards;}
@keyframes slideOut { to { transform: translateX(100%); opacity: 0; }}
.icon { display: flex; flex-shrink: 0;}
.toast.success .icon { color: var(--color-success);}
.toast.error .icon { color: var(--color-error);}
.toast.info .icon { color: var(--color-info);}
.message { flex: 1; margin: 0; font-size: 0.9375rem;}
.closeButton { display: flex; padding: 0.25rem; border: none; background: transparent; color: var(--color-text-muted); cursor: pointer; border-radius: 4px; transition: background-color 0.15s ease;}
.closeButton:hover { background: var(--color-hover);}
@media (prefers-reduced-motion: reduce) { .toast { animation: fadeIn 0.15s ease; } .toast.exiting { animation: fadeOut 0.15s ease forwards; } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeOut { to { opacity: 0; } }}Toast Container and Hook
import { create } from 'zustand';import { createPortal } from 'react-dom';import { Toast } from '@/components/ui/Toast';import styles from './ToastContainer.module.css';
interface ToastItem { id: string; message: string; type: 'success' | 'error' | 'info';}
interface ToastStore { toasts: ToastItem[]; addToast: (message: string, type?: ToastItem['type']) => void; removeToast: (id: string) => void;}
export const useToastStore = create<ToastStore>((set) => ({ toasts: [], addToast: (message, type = 'info') => { const id = `toast-${Date.now()}`; set((state) => ({ toasts: [...state.toasts, { id, message, type }], })); }, removeToast: (id) => { set((state) => ({ toasts: state.toasts.filter((t) => t.id !== id), })); },}));
// Convenience hookexport function useToast() { const { addToast } = useToastStore();
return { success: (message: string) => addToast(message, 'success'), error: (message: string) => addToast(message, 'error'), info: (message: string) => addToast(message, 'info'), };}
// Container component to render toastsexport function ToastContainer() { const { toasts, removeToast } = useToastStore();
if (typeof document === 'undefined') return null;
return createPortal( <div className={styles.container} aria-label="Notifications"> {toasts.map((toast) => ( <Toast key={toast.id} message={toast.message} type={toast.type} onClose={() => removeToast(toast.id)} /> ))} </div>, document.body );}.container { position: fixed; top: 1rem; right: 1rem; z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem; max-width: calc(100vw - 2rem);}
@media (max-width: 480px) { .container { left: 1rem; right: 1rem; }}Wishlist Heart Animation
A satisfying heart animation when adding to wishlist:
import { useState } from 'react';import styles from './WishlistButton.module.css';
interface WishlistButtonProps { productId: string; isInWishlist: boolean; onToggle: (productId: string) => void;}
export function WishlistButton({ productId, isInWishlist, onToggle,}: WishlistButtonProps) { const [isAnimating, setIsAnimating] = useState(false);
const handleClick = () => { if (!isInWishlist) { setIsAnimating(true); setTimeout(() => setIsAnimating(false), 400); } onToggle(productId); };
return ( <button className={` ${styles.button} ${isInWishlist ? styles.active : ''} ${isAnimating ? styles.animating : ''} `} onClick={handleClick} aria-label={isInWishlist ? 'Remove from wishlist' : 'Add to wishlist'} aria-pressed={isInWishlist} > <svg className={styles.heart} viewBox="0 0 24 24" fill={isInWishlist ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2" > <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> </svg> </button> );}.button { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border: none; border-radius: 50%; background: white; color: var(--color-text-muted); cursor: pointer; transition: color 0.2s ease, transform 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);}
.button:hover { color: var(--color-heart); transform: scale(1.1);}
.button.active { color: var(--color-heart);}
.heart { width: 20px; height: 20px; transition: transform 0.2s ease;}
/* Pop animation when adding to wishlist */.button.animating .heart { animation: heartPop 0.4s ease;}
@keyframes heartPop { 0% { transform: scale(1); } 25% { transform: scale(1.3); } 50% { transform: scale(0.9); } 75% { transform: scale(1.1); } 100% { transform: scale(1); }}
@media (prefers-reduced-motion: reduce) { .button.animating .heart { animation: none; }}Key Takeaways
-
Subtle is better: Micro-interactions should be felt, not noticed. If users are paying attention to the animation, it’s too much.
-
Consistent timing: Use the same duration and easing values across your theme for a cohesive feel.
-
Immediate feedback: Every user action should have an immediate visual response, even if the actual operation takes longer.
-
Accessibility first: Always support
prefers-reduced-motion. Provide static alternatives for animated feedback. -
CSS when possible: Most micro-interactions can be achieved with CSS transitions and animations. Reserve JavaScript for complex sequences.
-
Touch-friendly: Remember mobile users. Make touch targets at least 44x44 pixels and ensure feedback works with touch events.
-
Don’t block interaction: Animations should never prevent users from taking the next action. Keep them quick and non-blocking.
-
Test on real devices: Animations that feel smooth in Chrome DevTools might stutter on older phones. Always test on actual hardware.
This concludes the Animation and Micro-interactions module. You now have the tools to add polish to every touchpoint in your Shopify theme, from page transitions to button clicks to form feedback. Remember: the best animations are the ones that users don’t consciously notice but would definitely miss if they were gone.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...