Install
$ agentstack add skill-gekkos-tech-agency-os-interface-polish ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
About
Interface Polish
The last 10% that separates "works" from "feels great". Apply these rules to an interface whose layout, palette, and structure are already decided. Do not change the design direction — refine its execution.
Work through the sections as a checklist against the actual code. Fix what's missing; leave what's already right.
1. Interactive states
Every interactive element needs four distinct states: rest, hover, active, focus-visible. Missing states are the #1 "feels unfinished" signal.
.button {
transition: background-color 150ms ease, box-shadow 150ms ease, transform 100ms ease;
}
.button:hover { background-color: var(--accent-hover); }
.button:active { transform: scale(0.98); transition-duration: 50ms; } /* press responds FASTER than hover */
.button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px; /* never outline: none without a replacement */
}
Rules:
- Hover changes must be subtle but unmistakable: one step of background shift, or 1–2px lift with shadow deepening — not both plus a color change.
:activeshould feel physically pressed: scale 0.97–0.98 or translateY(1px), with a shorter duration than hover.- Use
:focus-visible, not:focus, so mouse clicks don't paint rings but keyboards do. - Rows/list items: hover =
background-colorshift only; don't move them. - Disabled: reduce opacity to ~0.5 AND remove hover effects (
pointer-events: noneor explicit rules). - Touch: hover states must not be load-bearing; anything revealed on hover needs a touch/focus path too.
2. Transition timing and easing
- Durations: 100–150ms hover/press · 200ms dropdowns, tooltips · 250–300ms modals, drawers, layout shifts · 400–600ms only for page-level or decorative reveals. When unsure, subtract 50ms — slow transitions read as lag.
- Never
transition: all. List properties explicitly;allcauses accidental animation of layout properties and jank. - Easing:
ease-outfor things appearing/responding to the user (start fast, settle).ease-inorease-in-outfor things leaving.cubic-bezier(0.16, 1, 0.3, 1)(expo-out) makes entrances feel expensive. Bounce/overshoot only if the design system is playful — and subtle (≤8% past target). - Exit faster than enter: a modal opening at 250ms should close in ~180ms. Users wait for what appears; they've already dismissed what's leaving.
- Animate only compositor-friendly properties where possible:
transform,opacity. Animatingwidth/height/top/left/margincauses layout jank; if unavoidable, keep the element small or usetransformtricks. Never animatebox-shadowdirectly on large surfaces — crossfade a pseudo-element's opacity instead:
.card::after {
content: ""; position: absolute; inset: 0; border-radius: inherit;
box-shadow: var(--shadow-lg); opacity: 0; transition: opacity 200ms ease;
pointer-events: none;
}
.card:hover::after { opacity: 1; }
3. Enter/exit animations
Elements should arrive from somewhere, not pop into existence.
- Pattern: fade + small movement. Opacity 0→1 plus translateY(8–12px)→0, or scale(0.96–0.98)→1 for popovers/modals. Movement over ~16px looks theatrical.
- Origin matters: dropdowns scale/slide from the trigger (
transform-origin: top leftor as appropriate), toasts slide from the edge they live on, modals scale from center. - Exit animations require keeping the node until the animation ends. In CSS-only setups, prefer
@starting-style+transition-behavior: allow-discrete(modern) or toggle a class and remove ontransitionend. In React, use a presence utility or a two-step state (closing → closed).
dialog {
opacity: 1; transform: scale(1);
transition: opacity 200ms ease-out, transform 200ms cubic-bezier(0.16, 1, 0.3, 1),
overlay 200ms allow-discrete, display 200ms allow-discrete;
}
@starting-style { dialog[open] { opacity: 0; transform: scale(0.97); } }
dialog:not([open]) { opacity: 0; transform: scale(0.98); }
- Height-into-view (accordions): animate
grid-template-rows: 0fr → 1fron a grid wrapper withmin-height: 0on the child — smooth without measuring heights.
4. Stagger
Lists that animate in as one block feel cheap; a small cascade feels orchestrated.
- Delay step: 30–60ms between items. Total cascade should finish under ~600ms — cap the delay after 8–10 items (
min(index, 8) * 40ms). - Stagger only on first entrance (page load, list appearing) — not on every re-render or filter change.
.list > * { animation: item-in 350ms cubic-bezier(0.16, 1, 0.3, 1) backwards; }
.list > :nth-child(1) { animation-delay: 0ms; }
.list > :nth-child(2) { animation-delay: 40ms; }
/* or dynamically: */
document.querySelectorAll(".list > *").forEach((el, i) => {
el.style.animationDelay = `${Math.min(i, 8) * 40}ms`;
});
@keyframes item-in { from { opacity: 0; transform: translateY(10px); } }
animation-fill-mode: backwards prevents the pre-delay flash of the final state.
5. Shadows and elevation
- Single-layer shadows look flat. Layer a tight contact shadow with a soft ambient one:
--shadow-md: 0 1px 2px rgba(16, 24, 40, 0.06), /* contact: small blur, small offset */
0 8px 24px rgba(16, 24, 40, 0.10); /* ambient: large blur, large offset */
- Shadow color: tint toward the background/brand hue instead of pure black —
rgba(16,24,40,…)on cool UIs, warm equivalents on warm UIs. Pure black shadows look dirty on colored backgrounds. - Y-offset grows with elevation; blur ≈ 2–3× offset; opacity DROPS as blur grows.
- Border vs shadow: static containers on a page → 1px border (or background shift), no shadow or a whisper (
--shadow-xs). Floating ephemerals (menus, popovers, modals, drag previews) → shadow, plus a hairline border so edges survive on white-on-white. Shadow communicates "above the page"; if it doesn't float, it shouldn't cast much. - Dark mode: shadows nearly disappear against dark backgrounds — convey elevation with lighter surface colors and borders instead; keep only a faint shadow for grounding.
- Inset highlight for a crisp premium edge on raised elements:
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08)(dark UIs) as an extra layer.
6. Border-radius nesting
Concentric corners: inner radius = outer radius − padding. A 16px-radius card with 8px padding needs 8px-radius children; giving them 16px too makes corners visibly clash.
.card { border-radius: 16px; padding: 8px; }
.card > .thumb { border-radius: calc(16px - 8px); } /* = 8px */
- If the math gives ≤2px, use a small consistent value (2–4px) rather than 0 next to a large outer radius.
- Never mix radius scales on siblings (a 4px button beside a 12px button).
- Pills inside rounded containers are exempt (radius 999px reads as its own shape).
- Elements clipped at a rounded parent's edge need
overflow: hiddenon the parent or matching radius on the child, or corners will poke out.
7. Optical alignment
Mathematical centering is often visually wrong; trust the eye and nudge.
- Play/triangle icons in circles: shift right ~1–2px (visual mass sits left of the bounding box).
- Icons beside text: align to cap-height/x-height feel, not baseline math — usually
translateY(-0.5px – 1px)or flexboxalign-items: centerplus a nudge. - Text in buttons: many fonts render slightly low;
padding-bottom: 1pxless than top, or line-height tuning, makes labels look centered. - Large display text: the bounding box includes side bearings; pull the first line left with
margin-left: -0.05emto flush-align it with body text below. - Chevrons/arrows following labels: optical gap ≈ 4–6px, less than the grid would suggest.
- Rule of thumb: when something "looks off-center" but the inspector says it's centered, the inspector is wrong. Nudge by 1–2px and re-check by eye (or screenshot).
8. Typography details
- Tabular numbers wherever digits change or align (tables, timers, counters, prices in lists):
font-variant-numeric: tabular-nums;— otherwise columns wobble and timers jitter. - Letter-spacing scales inversely with size: large display text needs negative tracking (
-0.01emto-0.03emabove ~32px); small ALL-CAPS labels need positive tracking (0.04em–0.08em). Body text: none. text-wrap: balanceon headings (≤4 lines);text-wrap: prettyon paragraphs to prevent orphan words.- Font smoothing: on macOS, light-on-dark text renders heavy;
-webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;slims it. Apply globally on dark UIs; use judgment on light ones. - Line-height pairs inversely with size: body 1.5–1.65, headings 1.1–1.2, giant display 0.95–1.05.
- Truncation: single line
text-overflow: ellipsis; white-space: nowrap; overflow: hidden;; multi-line-webkit-line-clamp. Always addtitleor a tooltip for truncated content. - Hyphens/
overflow-wrap: anywhereon user-generated strings so long tokens can't blow layouts. - Interpolate
font-variation-settings/weight rather than swapping weights on hover (weight-swap causes width jump; if you must, reserve width with a bold hidden twin).
9. Image treatment
- Subtle inset outline stops light images from bleeding into light backgrounds:
.img-frame { position: relative; }
.img-frame::after {
content: ""; position: absolute; inset: 0; border-radius: inherit;
box-shadow: inset 0 0 0 1px rgba(16, 24, 40, 0.08);
pointer-events: none;
}
- Reserve space to kill layout shift: always set
width/heightattributes oraspect-ratioon the container;object-fit: coverfor fills. - Background-color placeholder (
background: var(--surface-2)) behind every image so slow loads show a designed surface, not a void. - Avatars: add the inset ring above plus a background color for missing images; never let a broken-image glyph render.
- Hover zoom belongs inside an
overflow: hiddenframe: image scales 1→1.03–1.05, frame stays put. loading="lazy"below the fold; never on the LCP/hero image.
10. Scroll behavior
scroll-margin-topon anchor targets so sticky headers don't cover them:[id] { scroll-margin-top: calc(var(--header-h) + 16px); }scroll-behavior: smoothfor in-page anchors — gate it behind reduced-motion (see §11).- Horizontal scrollers (chip rows, card rails):
scroll-snap-type: x mandatory+scroll-snap-align: start, hide scrollbar only if arrows/affordances exist, addoverscroll-behavior-x: contain. - Modals/drawers: lock body scroll while open (
overflow: hiddenon body oroverscroll-behavior: containon the panel) — background scroll bleed feels broken. - Custom scrollbars: thin and unobtrusive is fine (
scrollbar-width: thin; scrollbar-color: var(--border) transparent); never remove scrollbars from long content. - Scroll-triggered reveals: threshold ~0.15–0.25, animate once, never re-hide on scroll-up:
const io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); }
}, { threshold: 0.2 });
document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));
11. Reduced motion
Non-negotiable. Every animation added by this pass must respect prefers-reduced-motion.
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
- The blanket rule above is the safety net; prefer targeted alternatives where motion carries meaning (e.g. keep the fade, drop the movement).
- In JS animation code, check
matchMedia("(prefers-reduced-motion: reduce)").matchesbefore starting loops, parallax, or auto-playing sequences. - Opacity fades are generally safe under reduced motion; large translations, scaling, parallax, and auto-scroll are not.
Final pass checklist
- [ ] All interactive elements: hover, active, focus-visible, disabled states present and distinct.
- [ ] No
transition: all; all durations ≤300ms in-app; exits faster than entrances. - [ ] Entering elements fade+move ≤16px; ephemerals originate from their trigger.
- [ ] First-load lists stagger ≤60ms/item, capped.
- [ ] Shadows layered and tinted; borders on static, shadows on floating; dark mode uses surface steps.
- [ ] Nested radii follow inner = outer − padding.
- [ ] Icon/text alignment checked by eye, not just by flexbox.
- [ ] Tabular nums on data; tracking tightened on display, widened on caps labels; headings balanced.
- [ ] Images: aspect-ratio reserved, placeholder background, inset outline where needed.
- [ ] Anchor scroll-margin set; body locked under modals; reveals fire once.
- [ ]
prefers-reduced-motionhandled globally and in JS.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Gekkos-tech
- Source: Gekkos-tech/agency-os
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.