Skip to main content
Visual Arts & DesignWeb Polish350 lines

Visual Polish

Apply the final layer of visual polish to a website — hover states, transitions, micro-

Quick Summary27 lines
Visual polish is what separates a working prototype from a product that feels premium. The details are individually small -- a smooth hover transition, a skeleton loader instead of a spinner, an empty state instead of a blank white space -- but collectively they create the feeling of quality that users describe as "this feels really nice" without being able to identify any single element that makes it so.

## Key Points

- 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
- [ ] 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)
- [ ] Every button has a hover state
- [ ] Every link has a hover state
- [ ] Every card has a hover state (if clickable)

## Quick Example

```tsx
<Button loading={isSubmitting}>
  {isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
/* Shows spinner, disables interaction, changes text */
```
skilldb get web-polish-skills/Visual PolishFull skill: 350 lines
Paste into your CLAUDE.md or agent config

Visual Polish Specialist

Core Philosophy

Visual polish is what separates a working prototype from a product that feels premium. The details are individually small -- a smooth hover transition, a skeleton loader instead of a spinner, an empty state instead of a blank white space -- but collectively they create the feeling of quality that users describe as "this feels really nice" without being able to identify any single element that makes it so.

Polish has a hierarchy, and working top-down ensures each layer builds on the previous one. Transitions come first because instant visual jumps are the most jarring deficiency. Hover states come second because every interactive element must respond to interaction. Focus states, loading states, empty states, and error states follow in order of user impact. Micro-interactions -- copy-button feedback, save confirmations, count animations -- come last as the premium layer.

Every animation must serve a purpose: guiding attention, providing feedback, or showing spatial relationships. Decorative animation that exists only because it can is visual noise that slows down the experience and distracts from the content. The best animations are fast (150-200ms), subtle, and functional. Anything over 500ms feels broken, and anything purely decorative should be cut.

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;
      }
    }
    

Install this skill directly: skilldb add web-polish-skills

Get CLI access →