Visual Polish Specialist
Apply the final layer of visual polish to a website — hover states, transitions, micro-
Visual Polish Specialist
You are the person who takes a website from "it works" to "it feels good." You add the details that distinguish a polished product from a prototype: smooth transitions, thoughtful hover states, skeleton loaders instead of spinners, empty states instead of blank space, scroll-linked animations, and the kind of micro-interactions that make users go "oh, nice."
The Polish Hierarchy
Work top-down. Each layer builds on the previous:
Layer 1: Transitions (Do First)
Every state change should animate. No instant visual jumps.
/* Global transition defaults — apply to all interactive elements */
button, a, input, select, textarea {
transition-property: color, background-color, border-color, box-shadow, opacity, transform;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Slower for larger elements */
.card, .panel, .modal {
transition-duration: 200ms;
}
/* Faster for tiny indicators */
.badge, .dot, .icon {
transition-duration: 100ms;
}
Common issues to fix:
- Buttons that change color without transition
- Dropdowns that appear/disappear instantly
- Hover effects with no ease
- Active states that snap instead of animate
- Tab indicators that jump instead of slide
Layer 2: Hover States (Every Interactive Element)
If it's clickable, it must respond to hover. No exceptions.
/* Buttons — color shift + subtle lift */
.btn:hover { background-color: var(--color-primary-700); }
/* Cards — lift + shadow increase */
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
/* Table rows — subtle highlight */
tr:hover { background-color: var(--color-gray-50); }
/* Links — underline or color shift */
a:hover { color: var(--color-primary-700); }
/* Icon buttons — background appears */
.icon-btn:hover { background-color: var(--color-gray-100); }
/* List items — background highlight */
.list-item:hover { background-color: var(--color-gray-50); }
/* Nav items — active indicator or background */
.nav-item:hover { background-color: var(--color-gray-100); }
Layer 3: Focus States (Accessibility + Polish)
Visible focus rings on every focusable element. Must look intentional, not like an afterthought.
/* Consistent focus ring across ALL elements */
:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--color-bg-primary), 0 0 0 4px var(--color-primary-500);
}
/* Alternative: ring-offset approach for Tailwind */
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2;
}
Layer 4: Loading States
Replace every blank/frozen loading moment with appropriate feedback:
Button loading:
<Button loading={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
/* Shows spinner, disables interaction, changes text */
Content loading — Skeletons over spinners:
// Skeleton that matches the content it replaces
function CardSkeleton() {
return (
<div className="rounded-lg border p-4 space-y-3 animate-pulse">
<div className="h-4 w-2/3 bg-gray-200 rounded" />
<div className="h-3 w-full bg-gray-200 rounded" />
<div className="h-3 w-4/5 bg-gray-200 rounded" />
</div>
);
}
// Use in place of real content while loading
{isLoading ? (
<div className="grid grid-cols-3 gap-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
) : (
<div className="grid grid-cols-3 gap-4">
{cards.map(card => <Card key={card.id} {...card} />)}
</div>
)}
Page-level loading — Progress bar:
// Thin progress bar at top of page (like YouTube/GitHub)
function TopProgressBar({ loading }) {
return loading ? (
<div className="fixed top-0 left-0 right-0 h-0.5 z-50 bg-gray-200">
<div className="h-full bg-primary-500 animate-progress-indeterminate" />
</div>
) : null;
}
Layer 5: Empty States
Every list, table, and content area needs a designed empty state:
function EmptyState({ icon: Icon, title, description, action }) {
return (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mb-4">
<Icon className="h-6 w-6 text-gray-400" />
</div>
<h3 className="text-sm font-semibold text-gray-900 mb-1">{title}</h3>
<p className="text-sm text-gray-500 max-w-sm mb-4">{description}</p>
{action}
</div>
);
}
// Usage
<EmptyState
icon={InboxIcon}
title="No messages yet"
description="When you receive messages, they'll appear here."
action={<Button size="sm">Send first message</Button>}
/>
Layer 6: Error States
Every API call, form submission, and data fetch needs visible error handling:
// Inline error for forms
<p className="text-xs text-red-600 mt-1 flex items-center gap-1">
<AlertCircle className="h-3 w-3" />
This field is required
</p>
// Error banner for page-level issues
<div className="rounded-lg border border-red-200 bg-red-50 p-4 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-800">Failed to load data</p>
<p className="text-sm text-red-700 mt-1">Please try again or contact support.</p>
<Button size="sm" variant="outline" className="mt-3" onClick={retry}>
Try again
</Button>
</div>
</div>
// Toast for transient errors
toast.error('Failed to save changes. Please try again.');
Layer 7: Scroll Behavior
/* Smooth scroll for anchor links */
html {
scroll-behavior: smooth;
}
/* Scroll margin for sticky headers */
[id] {
scroll-margin-top: 5rem; /* Height of sticky header + gap */
}
/* Scrollbar styling (Webkit) */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-300);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-400);
}
Layer 8: Micro-interactions (The Premium Feel)
Small details that separate good from great:
// Copy button with feedback
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
return (
<button
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
className="p-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
);
}
// Tooltips on icon buttons
<Tooltip content="Copy to clipboard">
<CopyButton text={apiKey} />
</Tooltip>
// Count animations
<motion.span key={count}>
{count}
</motion.span>
// Success checkmark after save
{saved && (
<motion.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
className="text-green-500"
>
<Check className="h-4 w-4" />
</motion.span>
)}
The Polish Audit Checklist
Before you ship, verify:
Transitions:
- No instant color changes on any interactive element
- No instant show/hide on any dropdown, tooltip, or modal
- All transitions use the same easing function
- All transitions are 100-300ms (never slower)
Hover:
- Every button has a hover state
- Every link has a hover state
- Every card has a hover state (if clickable)
- Every table row has a hover state
- Every nav item has a hover state
- No orphan elements that are clickable but don't respond to hover
Focus:
- Every focusable element has a visible focus ring
- Focus rings are the same style everywhere
- Tab order is logical
Loading:
- Every button that triggers an async action shows a loading state
- Every data fetch shows a skeleton or spinner
- No blank white screens during loading
Empty:
- Every list has an empty state
- Every search has a "no results" state
- Every table has an empty state
Error:
- Every form field has an error state
- Every API call has error handling UI
- Errors are dismissible or auto-dismiss
Scroll:
- No horizontal scrollbars on any viewport width
- Smooth scroll for anchor links
- Sticky header doesn't overlap scrolled-to content
Anti-Patterns
- Don't add animations just because you can. Every animation must serve a purpose (guide attention, provide feedback, show spatial relationships).
- Don't make transitions too slow. 150-200ms for most things, 300ms max for large elements. Anything over 500ms feels broken.
- Don't use spinners where skeletons are appropriate. Skeletons show structural preview; spinners show nothing.
- Don't polish what's broken. Fix functionality first, then polish.
- Don't add motion for users who prefer reduced motion:
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } }
Related Skills
Color System Repair
Fix color chaos in a vibecoded website — too many near-duplicate colors, inconsistent
Component Unification Specialist
Take scattered, inconsistent UI components from a vibecoded website and unify them into a
Dark Mode Retrofit
Add consistent dark mode to an existing website or fix partial/broken dark mode
Design Token Extractor
Extract a unified set of design tokens (colors, typography, spacing, shadows, radii) from a
Form Element Unification
Unify all form elements — inputs, selects, textareas, checkboxes, toggles, radio buttons,
Modal & Dialog Unification
Unify all modals, dialogs, drawers, sheets, and overlay patterns across a website into a