Style loading states, transitions, and errors with CSS.
State Attributes
html★ sets data attributes that you can target with CSS:
CSS
/* Loading state on trigger element */[data-loading] { opacity: 0.6; pointer-events: none; cursor: wait;} /* Swapping state on target */[data-swapping] { /* View Transition in progress */} /* Success state (brief) */[data-success] { /* Flash of success */} /* Error state */[data-error] { outline: 2px solid #ef4444;}
Loading Indicators
Simple Opacity
CSS
[data-loading] { opacity: 0.5; pointer-events: none;}
Spinner
CSS
[data-loading]::after { content: ""; position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; border: 2px solid #e5e7eb; border-top-color: #2563eb; border-radius: 50%; animation: spin 0.8s linear infinite;} @keyframes spin { to { transform: translateY(-50%) rotate(360deg); }}
Parent Awareness with :has()
CSS
/* Style nav when any child is loading */nav:has([data-loading]) { cursor: wait;} /* Dim form while submitting */form:has([data-loading]) { opacity: 0.7; pointer-events: none;}
View Transitions
Basic Setup
CSS
#main { view-transition-name: main-content;} ::view-transition-old(main-content) { animation: fade-out 0.2s ease-out;} ::view-transition-new(main-content) { animation: fade-in 0.2s ease-in;} @keyframes fade-out { to { opacity: 0; }} @keyframes fade-in { from { opacity: 0; }}
Slide Transition
CSS
::view-transition-old(main-content) { animation: slide-out-left 0.2s ease-out;} ::view-transition-new(main-content) { animation: slide-in-right 0.2s ease-in;} @keyframes slide-out-left { to { opacity: 0; transform: translateX(-30px); }} @keyframes slide-in-right { from { opacity: 0; transform: translateX(30px); }}
Different Transitions for Different Elements
CSS
header { view-transition-name: header; }main { view-transition-name: main; }aside { view-transition-name: sidebar; } /* Header doesn't animate */::view-transition-old(header),::view-transition-new(header) { animation: none;} /* Main slides */::view-transition-old(main) { animation: slide-out 0.2s;} /* Sidebar fades */::view-transition-old(sidebar) { animation: fade-out 0.15s;}
Error Styling
CSS
/* Error outline */[data-error] { outline: 2px solid #ef4444; outline-offset: 2px;} /* Show error message */[data-error]::after { content: attr(data-error-message); display: block; color: #ef4444; font-size: 0.85rem; margin-top: 5px;} /* Error in form context */form [data-error] { border-color: #ef4444;}
SSE Connection States
CSS
/* Connected indicator */[data-sse-connected]::before { content: "●"; color: #22c55e; margin-right: 5px;} /* Error indicator */[data-sse-error]::before { content: "●"; color: #ef4444; margin-right: 5px;}
Button States
CSS
button { position: relative; transition: all 0.15s;} button[data-loading] { color: transparent;} button[data-loading]::after { content: ""; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 16px; height: 16px; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite;} button:disabled,button[data-loading] { opacity: 0.6; cursor: not-allowed;}
Complete Example
CSS
/* Loading states */[data-loading] { opacity: 0.6; pointer-events: none;} /* View Transitions */#content { view-transition-name: content;} ::view-transition-old(content) { animation: fade-slide-out 0.15s ease-out;} ::view-transition-new(content) { animation: fade-slide-in 0.15s ease-in;} @keyframes fade-slide-out { to { opacity: 0; transform: translateY(-10px); }} @keyframes fade-slide-in { from { opacity: 0; transform: translateY(10px); }} /* Errors */[data-error] { outline: 2px solid red;}
Tip: Keep transitions short (100-200ms) for a snappy feel. Longer animations can make the UI feel sluggish.