Micro-interactions sell intent. A slight nudge on a chevron hints at navigation. A pulse on a search lens suggests action. A tiny ring on a bell conveys fresh updates. In this tutorial you will build a small CSS-only icon set and wire up tasteful micro-interactions that improve clarity without adding JavaScript. By the end, you will have reusable patterns for hover, focus, and stateful motion that you can apply across your UI.
Why Micro-interactions Matter
Icons communicate quickly, but static glyphs can be ambiguous. Micro-interactions help users predict outcomes: a search lens that gently expands implies input, a right arrow that nudges forward implies progression. CSS makes this possible with transforms and keyframes that run on the compositor, which keeps interactions smooth. You avoid dependence on JavaScript listeners for simple feedback, and you keep your icon system themable with CSS variables. When icons are decorative, you can keep them hidden from assistive tech, and when they carry meaning, you can label them cleanly.
Prerequisites
You need a basic understanding of HTML and CSS. We will compose icons from boxes, borders, and pseudo-elements, then add motion with transitions and keyframes. We will also set up a reduced-motion strategy and keyboard focus states to keep these effects accessible.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
Here is the full HTML we will use. Each icon is a button with a visible graphic element and an offscreen text label for screen readers. The notification badge sits in the bell button as a separate element so we can animate it independently.
<div class="icons">
<button class="icon icon--search" type="button" aria-label="Search">
<span class="icon__graphic" aria-hidden="true"></span>
<span class="sr-only">Search</span>
</button>
<button class="icon icon--arrow" type="button" aria-label="Next">
<span class="icon__graphic" aria-hidden="true"></span>
<span class="sr-only">Next</span>
</button>
<button class="icon icon--heart" type="button" aria-label="Like" aria-pressed="false">
<span class="icon__graphic" aria-hidden="true"></span>
<span class="sr-only">Like</span>
</button>
<button class="icon icon--bell" type="button" aria-label="Notifications">
<span class="icon__graphic" aria-hidden="true"></span>
<span class="icon__badge" aria-hidden="true">3</span>
<span class="sr-only">Notifications</span>
</button>
</div>
The .icons wrapper simply lays the buttons out in a row. Each .icon button holds a .icon__graphic that we will draw with CSS. The .sr-only spans provide accessible names while keeping the visual focus on the glyphs. The heart uses aria-pressed to reflect a toggled state if you wire it later with a small script, but we will focus on pure CSS motion today.
Step 2: The Basic CSS & Styling
Set up a light design system with custom properties for color, size, and timing. The base button resets remove default styles, and a focus ring helps keyboard users. The .icon__graphic is a square box that will host our shapes with pseudo-elements. A simple grid arranges the icons and creates breathing room.
/* CSS */
/* CSS */
:root {
--icon-size: 44px;
--stroke: 2px;
--radius: 10px;
--bg: #0f172a;
--ink: #e2e8f0;
--accent: #38bdf8;
--danger: #ef4444;
--success: #22c55e;
--shadow: 0 1px 0 rgba(0,0,0,.2), 0 8px 24px rgba(0,0,0,.24);
--speed-fast: 120ms;
--speed: 200ms;
--ease: cubic-bezier(.2,.8,.2,1);
}
*,
*::before,
*::after { box-sizing: border-box; }
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, Apple Color Emoji, Segoe UI Emoji;
background: radial-gradient(1200px 800px at 20% -10%, #1f2937, #0b1220);
color: var(--ink);
min-height: 100svh;
display: grid;
place-items: center;
margin: 0;
}
.icons {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.icon {
-webkit-tap-highlight-color: transparent;
appearance: none;
border: 0;
background: #0b1220;
color: var(--ink);
border-radius: var(--radius);
padding: 10px;
width: calc(var(--icon-size) + 20px);
height: calc(var(--icon-size) + 20px);
display: grid;
place-items: center;
box-shadow: var(--shadow);
cursor: pointer;
transition: transform var(--speed) var(--ease), box-shadow var(--speed) var(--ease);
}
.icon:active {
transform: translateY(1px) scale(.98);
box-shadow: 0 1px 0 rgba(0,0,0,.15), 0 5px 18px rgba(0,0,0,.22);
}
.icon:focus-visible {
outline: 2px solid transparent;
box-shadow: 0 0 0 3px rgba(56,189,248,.45), var(--shadow);
}
.icon__graphic {
position: relative;
inline-size: var(--icon-size);
block-size: var(--icon-size);
display: inline-block;
}
.sr-only {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
/* Generic helpers for lines and circles via pseudo-elements */
.icon__graphic::before,
.icon__graphic::after {
content: "";
position: absolute;
inset: 0;
}
Advanced Tip: Keep sizes, colors, and timing values in CSS variables. This gives you instant theming and lets you scale motion for different contexts by overriding a single custom property on a parent container.
Step 3: Building the Search Icon Micro-interaction
The search icon is a magnifying glass: a circle lens plus a handle. The subtle motion on hover and focus grows the lens by a few percent and slides the handle forward, which reads as "ready" without shouting.
/* CSS */
/* CSS */
/* SEARCH: lens (circle) + handle */
.icon--search .icon__graphic {
--lens: 22px;
--handle: 14px;
}
.icon--search .icon__graphic::before {
/* Lens */
width: var(--lens);
height: var(--lens);
border-radius: 50%;
border: var(--stroke) solid var(--ink);
top: 9px;
left: 9px;
transition: transform var(--speed) var(--ease), border-color var(--speed-fast) var(--ease);
transform-origin: 50% 50%;
}
.icon--search .icon__graphic::after {
/* Handle */
width: var(--stroke);
height: var(--handle);
background: var(--ink);
top: 22px;
left: 28px;
transform: rotate(45deg);
transform-origin: 0 0;
transition: transform var(--speed) var(--ease), background-color var(--speed-fast) var(--ease);
}
/* Hover/focus micro-interaction */
.icon--search:is(:hover, :focus-visible) .icon__graphic::before {
transform: scale(1.06);
border-color: var(--accent);
}
.icon--search:is(:hover, :focus-visible) .icon__graphic::after {
transform: rotate(45deg) translateY(1px) translateX(2px);
background: var(--accent);
}
How This Works (Code Breakdown)
The lens uses a bordered circle drawn by .icon__graphic::before. A border-based circle keeps the center transparent so the handle reads clearly on top. The handle uses ::after as a thin rectangle rotated 45 degrees, positioned at the lower-right quadrant of the lens. Both parts opt into transitions for transform and color to keep the hover soft and quick. The transform-origin on the lens is its center, which avoids a wobble as it scales.
This icon is a classic candidate for the circular techniques in the shapes library. If you want a refresher on creating a perfect round base with pure CSS, see the guide to a circle with CSS. A lens is just a stroked circle, so the same core idea applies here.
Step 4: Building the Arrow Right Micro-interaction
The arrow combines a thin shaft and a triangle head. On hover and focus, the whole arrow shifts a few pixels to the right, the head scales slightly, and the accent color kicks in. This reads as a directional cue without moving the layout.
/* CSS */
/* CSS */
/* ARROW: shaft + triangle head */
.icon--arrow .icon__graphic::before {
/* Shaft: centered horizontal line */
content: "";
position: absolute;
top: 50%;
left: 10px;
right: 16px;
height: var(--stroke);
background: var(--ink);
transform: translateY(-50%);
transition: background-color var(--speed-fast) var(--ease);
}
.icon--arrow .icon__graphic::after {
/* Triangle head using CSS borders */
width: 0; height: 0;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
border-left: 10px solid var(--ink);
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
transition: transform var(--speed) var(--ease), border-left-color var(--speed-fast) var(--ease);
}
/* Micro-interaction */
.icon--arrow:is(:hover, :focus-visible) .icon__graphic {
transform: translateX(2px);
transition: transform var(--speed) var(--ease);
}
.icon--arrow:is(:hover, :focus-visible) .icon__graphic::before {
background: var(--accent);
}
.icon--arrow:is(:hover, :focus-visible) .icon__graphic::after {
border-left-color: var(--accent);
transform: translateY(-50%) scale(1.08);
}
How This Works (Code Breakdown)
The shaft is a thin horizontal bar centered with top: 50% and translateY(-50%). Using left and right anchors makes it flexible; the head sits 10px from the right edge. The head is a pure CSS triangle built with borders, where two transparent sides form the slanted edges and the colored border becomes the arrow point. The head scales on hover for a touch of emphasis while the whole graphic nudges to the right by 2px. Transforms avoid reflow and keep the motion smooth.
If you need a deeper look at the border triangle technique, review how to make a triangle right. This arrow head uses the same property set with a different size. This is a strong pattern for carets, tooltips, and popovers as well.
Advanced Techniques: Adding Animations & Hover Effects
Now let us polish the set with two more icons. The heart gets a gentle beat on hover and a stronger beat on a toggled state. The bell gets a short ring and a badge pop. We will also add a soft search pulse for focus, and we will guard all of this behind a reduced-motion preference.
/* CSS */
/* CSS */
/* Reduced motion: turn keyframes into static states while keeping color intent */
@media (prefers-reduced-motion: reduce) {
.icon *,
.icon::before,
.icon::after {
animation: none !important;
transition-duration: 1ms !important;
}
}
/* HEART: two circles + a square make a heart */
.icon--heart .icon__graphic::before,
.icon--heart .icon__graphic::after {
width: 20px; height: 32px;
background: var(--danger);
border-radius: 10px 10px 0 0;
top: 12px;
left: 18px;
transform-origin: bottom center;
transition: transform var(--speed) var(--ease), background-color var(--speed-fast) var(--ease);
}
.icon--heart .icon__graphic::after {
left: auto; right: 18px;
}
.icon--heart .icon__graphic {
/* the diamond that connects lobes */
}
.icon--heart .icon__graphic {
position: relative;
}
.icon--heart .icon__graphic::before {
transform: rotate(-45deg) translateX(-3px);
}
.icon--heart .icon__graphic::after {
transform: rotate(45deg) translateX(3px);
}
.icon--heart .icon__graphic {
/* bottom diamond via an extra layer */
}
.icon--heart .icon__graphic::before,
.icon--heart .icon__graphic::after { box-shadow: 0 0 0 0 rgba(0,0,0,0); }
.icon--heart .icon__graphic {
/* create the point using a rotated square */
}
.icon--heart .icon__graphic .point { display: none; } /* reserved if you prefer an extra span */
/* Hover beat */
@keyframes heart-beat {
0%, 100% { transform: scale(1); }
20% { transform: scale(1.08); }
40% { transform: scale(1); }
60% { transform: scale(1.06); }
80% { transform: scale(1); }
}
.icon--heart:is(:hover, :focus-visible) .icon__graphic {
animation: heart-beat 600ms var(--ease);
}
/* Optional toggled state */
.icon--heart[aria-pressed="true"] .icon__graphic::before,
.icon--heart[aria-pressed="true"] .icon__graphic::after {
background: var(--success);
}
/* BELL: simple dome + clapper, ring on hover */
.icon--bell .icon__graphic::before {
/* dome */
width: 28px; height: 20px;
background: transparent;
border: var(--stroke) solid var(--ink);
border-bottom-width: var(--stroke);
border-top-left-radius: 18px;
border-top-right-radius: 18px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
left: 8px; top: 10px;
transition: border-color var(--speed-fast) var(--ease);
}
.icon--bell .icon__graphic::after {
/* clapper */
width: 6px; height: 6px;
background: var(--ink);
border-radius: 50%;
left: 21px; top: 26px;
transition: background-color var(--speed-fast) var(--ease);
}
/* Ring animation */
@keyframes bell-ring {
0% { transform: rotate(0); }
15% { transform: rotate(-10deg); }
30% { transform: rotate(8deg); }
45% { transform: rotate(-6deg); }
60% { transform: rotate(4deg); }
75% { transform: rotate(-2deg); }
100% { transform: rotate(0); }
}
.icon--bell:is(:hover, :focus-visible) .icon__graphic {
transform-origin: 22px 14px;
animation: bell-ring 520ms var(--ease);
}
.icon--bell:is(:hover, :focus-visible) .icon__graphic::before {
border-color: var(--accent);
}
.icon--bell:is(:hover, :focus-visible) .icon__graphic::after {
background: var(--accent);
}
/* Badge pop */
.icon--bell .icon__badge {
position: absolute;
right: 8px; top: 8px;
min-width: 18px; height: 18px;
padding: 0 5px;
background: var(--danger);
color: white;
border-radius: 999px;
font-size: 11px; line-height: 18px;
text-align: center;
transform-origin: 100% 0%;
transition: transform var(--speed) var(--ease);
}
@keyframes badge-pop {
0% { transform: scale(.8); }
50% { transform: scale(1.15); }
100% { transform: scale(1); }
}
.icon--bell:is(:hover, :focus-visible) .icon__badge {
animation: badge-pop 300ms var(--ease);
}
/* SEARCH: focus pulse for input intent */
@keyframes pulse-outline {
0% { box-shadow: 0 0 0 0 rgba(56,189,248,.0); }
50% { box-shadow: 0 0 0 6px rgba(56,189,248,.12); }
100% { box-shadow: 0 0 0 0 rgba(56,189,248,.0); }
}
.icon--search:is(:focus-visible) {
animation: pulse-outline 900ms ease-in-out;
}
The heart is a classic CSS build: two rounded rectangles rotated to form the lobes. The beat animation targets the .icon__graphic container to scale the whole shape in a rhythm that reads as organic. The bell uses a bordered dome and a circular clapper. The ring keyframes alternate small rotations around a hinge point near the top center, which gives a convincing swing.
The badge uses a pill with a maxed border-radius. If you want to revisit shape basics for circular badges, check the quick guide to a circle with CSS. For a full walkthrough of the lens approach, the library’s magnifying glass icon article goes deeper into structure and variations.
Accessibility & Performance
Micro-interactions should add meaning, not confusion. Always keep keyboard users and motion sensitivity in mind. Decorative icons belong out of the accessibility tree, while meaningful icons need a clear label. Color alone should not carry critical information.
Accessibility
For decorative uses, set aria-hidden=”true” on the graphic only, and keep the button’s aria-label meaningful. The sample uses aria-label directly on the button while the graphic spans are hidden with aria-hidden=”true”. Keyboard focus uses :focus-visible styles on the button. The @media (prefers-reduced-motion: reduce) rule collapses motion to a near-instant transition so users who disable motion are not forced through keyframe sequences. If you wire the heart as a toggle, mirror its state with aria-pressed=”true” or “false”.
Performance
All effects here rely on transform and opacity rather than properties that trigger layout or paint. Border color and background color changes are cheap. Box-shadow pulses can get heavy on large elements, so the search pulse keeps the effect short and faint. Reserve will-change for truly expensive animations and remove it when not needed; overusing it can hurt memory usage. Keep durations under 300ms for responsive feel, and prefer cubic-bezier easing with a strong out curve for snappy yet friendly motion.
The last closing paragraph
You built four icons and added motion cues that communicate intent: a lens that primes for input, a forward nudge on navigation, a subtle heart beat, and a bell that rings with a badge pop. These patterns sit well inside any design system, and they scale with simple variable overrides. Use them as building blocks and start crafting a custom set of animated icons that speak your product’s language.