How to Make a Pure CSS Icon Set


You want a small, themeable icon set that ships zero images, no external fonts, and no SVG sprites. By the end of this guide, you will build a compact, extensible set of icons using only CSS. You will learn a repeatable pattern for drawing icons with borders, backgrounds, gradients, and pseudo-elements. The sample set includes Search, Hamburger, Close, Arrow Right, and Plus. The same techniques scale to dozens of icons without changing your HTML structure or adding network weight.

Why Pure CSS Icons Matter

Icon fonts blur at non-integer font sizes and come with accessibility tradeoffs. SVGs are precise, but they add authoring overhead and often require a build step or a sprite pipeline. Pure CSS icons render crisply on any screen density, inherit color and size from CSS variables, and can adapt to dark mode or brand themes with no asset swaps. They animate with transforms, which keeps the GPU happy. For simple interface symbols like arrows, close buttons, and menu toggles, CSS is fast to write and easy to maintain.

Prerequisites

You should be comfortable with basic HTML and CSS. This guide uses CSS custom properties to control size and color, and relies on pseudo-elements for drawing. Everything runs in modern browsers with no prefixing.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

The icon set is a simple grid of buttons. Each icon uses one base class for shared sizing and an icon-specific modifier for the drawing logic. Most icons draw with pseudo-elements, so the buttons stay empty. The Hamburger icon includes one span to support the bar morph animation.


<div class="icon-set">
  <button class="icon icon--search" aria-label="Search" type="button"></button>

  <button class="icon icon--hamburger" aria-label="Open menu" aria-pressed="false" type="button">
    <span class="icon__bar" aria-hidden="true"></span>
  </button>

  <button class="icon icon--close" aria-label="Close" type="button"></button>

  <button class="icon icon--arrow-right" aria-label="Next" type="button"></button>

  <button class="icon icon--plus" aria-label="Add" type="button"></button>
</div>

Each button uses aria-label because icons often appear without visible text. Keep the base .icon class responsible for sizing, alignment, color, and focus. The modifier class defines the actual shape using borders, gradients, and transforms.

Step 2: The Basic CSS & Styling

The foundation sets up CSS variables for size, stroke, and color. Using custom properties lets you theme the entire set by toggling a class or data attribute. The base .icon class is a square canvas with a relative positioning context for its pseudo-elements. The .icon-set creates a consistent grid to preview and test the icons.

/* CSS */

/* CSS */
:root {
  --icon-size: 28;           /* icon box size in px */
  --stroke: 2;               /* default line thickness */
  --fg: #111;                /* icon color (currentColor) */
  --bg: #fff;                /* background for demo */
  --accent: #0a7cff;         /* hover/active color */
  --radius: 6px;             /* demo container rounding */
  --gap: 16px;               /* spacing in demo grid */
}

[data-theme="dark"] {
  --fg: #eee;
  --bg: #0b0d10;
  --accent: #7cc4ff;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: var(--bg);
  color: var(--fg);
  line-height: 1.5;
  padding: 24px;
}

.icon-set {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(56px, 1fr));
  gap: var(--gap);
  padding: var(--gap);
  background: rgba(127,127,127,0.08);
  border-radius: var(--radius);
}

.icon {
  --s: calc(var(--icon-size) * 1px);
  width: var(--s);
  height: var(--s);
  display: inline-block;
  position: relative;
  color: var(--fg);            /* currentColor source */
  background: transparent;
  border: 0;
  padding: 0;
  cursor: pointer;
  border-radius: 8px;          /* clickable area rounding */
}

.icon::before,
.icon::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
}

.icon:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.icon:hover { color: var(--accent); }

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}

Advanced Tip: Keep icon color on currentColor. Parent components can set color directly or through CSS variables, and every icon follows without overrides. Theme switching becomes a single style change on a parent attribute like data-theme.

Step 3: Building the Search Icon

The Search icon uses two parts: a circular lens and a 45-degree handle. The circle is a bordered ring; the handle is a small rectangle rotated and positioned at the bottom-right of the lens. This draws cleanly at any size because stroke thickness is a variable.

/* CSS */

/* CSS */
.icon--search {
  /* Nothing to draw on the element itself, we use pseudo-elements */
}

/* Lens */
.icon--search::before {
  width: 60%;
  height: 60%;
  border: calc(var(--stroke) * 1px) solid currentColor;
  border-radius: 50%;
  top: -6%;
  left: -6%;
  right: 0;
  bottom: 0;
}

/* Handle */
.icon--search::after {
  width: 38%;
  height: calc(var(--stroke) * 1px);
  background: currentColor;
  transform: rotate(45deg);
  transform-origin: left center;
  bottom: 18%;
  right: 12%;
}

How This Works (Code Breakdown)

We draw the lens with a border instead of a filled circle to keep the interior transparent, which helps when placing icons over solid or image backgrounds. The lens uses a border equal to var(–stroke), which keeps the line weight consistent across the set. The 60% size leaves breathing room inside the icon box so the shape does not feel cramped.

The handle is a thin rectangle using the same stroke thickness. Setting transform-origin to the left side places the pivot at the start of the handle, so rotating to 45 degrees swings the handle around a natural joint. The positioning nudges the handle toward the lower-right quadrant. If you want a filled lens for a bold style, swap the lens border for a background and reduce the handle width. If you want to review the circle technique used here, see how to make a circle with CSS and adapt stroke-versus-fill based on your UI density.

Step 4: Building the Arrow, Close, Hamburger, and Plus Icons

These icons share a base idea: draw simple bars and triangles, then align them with absolute positioning inside the square canvas. The Arrow Right uses a shaft and a triangle head. The Close icon is two crossing bars. The Hamburger icon uses three bars and toggles into a Close icon using aria-pressed, which keeps the toggle accessible. The Plus icon draws one horizontal and one vertical bar.

/* CSS */

/* CSS */
/* Arrow Right */
.icon--arrow-right::before { /* shaft */
  height: calc(var(--stroke) * 1px);
  width: 60%;
  background: currentColor;
  left: 12%;
  right: auto;
  top: 50%;
  transform: translateY(-50%);
}
.icon--arrow-right::after { /* head (triangle) */
  width: 0; height: 0;
  border-top: 7px solid transparent;
  border-bottom: 7px solid transparent;
  border-left: 10px solid currentColor;
  right: 10%;
  top: 50%;
  transform: translateY(-50%);
}

/* Close (X) */
.icon--close::before,
.icon--close::after {
  width: 70%;
  height: calc(var(--stroke) * 1px);
  background: currentColor;
  top: 50%;
  left: 50%;
  transform-origin: center;
}
.icon--close::before {
  transform: translate(-50%, -50%) rotate(45deg);
}
.icon--close::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}

/* Hamburger (3 bars) with morph to Close on aria-pressed=true */
.icon--hamburger::before,
.icon--hamburger::after {
  content: "";
  position: absolute;
  left: 15%;
  right: 15%;
  height: calc(var(--stroke) * 1px);
  background: currentColor;
  transition: transform 200ms ease, top 200ms ease, bottom 200ms ease, opacity 150ms ease;
}
.icon--hamburger::before { top: 25%; }
.icon--hamburger::after  { bottom: 25%; }

/* Middle bar as a child span */
.icon--hamburger .icon__bar {
  position: absolute;
  left: 15%;
  right: 15%;
  top: 50%;
  height: calc(var(--stroke) * 1px);
  transform: translateY(-50%);
  background: currentColor;
  transition: transform 200ms ease, opacity 150ms ease;
}

/* Morph state */
.icon--hamburger[aria-pressed="true"] { color: var(--accent); }
.icon--hamburger[aria-pressed="true"]::before {
  top: 50%;
  transform: rotate(45deg);
}
.icon--hamburger[aria-pressed="true"]::after {
  bottom: auto;
  top: 50%;
  transform: rotate(-45deg);
}
.icon--hamburger[aria-pressed="true"] .icon__bar {
  opacity: 0;
  transform: translateY(-50%) scaleX(0);
}

/* Plus */
.icon--plus::before { /* horizontal */
  width: 70%;
  height: calc(var(--stroke) * 1px);
  background: currentColor;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
.icon--plus::after { /* vertical */
  width: calc(var(--stroke) * 1px);
  height: 70%;
  background: currentColor;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

How This Works (Code Breakdown)

The Arrow Right uses a simple recipe: a shaft drawn as a thin rectangle and a triangle pointer. The triangle is a border-based shape: setting border-top and border-bottom to transparent and border-left to currentColor produces a perfect right-pointing triangle. This technique is the same one you would use when you make an arrow right with CSS, and it scales cleanly with stroke and icon size. If your icon becomes blurry, check that the triangle border sizes are integers at your chosen icon size.

The Close icon is two bars rotated ±45 degrees and centered with translate(-50%, -50%). Using transform-origin: center keeps the bars locked around the same anchor point. Because both bars share width and thickness, they look balanced at any scale. To make the X lighter, reduce width to 60% or drop stroke thickness.

The Hamburger icon uses ::before and ::after for the top and bottom bars and a child span for the middle bar. That choice makes the morph state straightforward. When aria-pressed=”true”, the top and bottom bars meet in the center and rotate into an X, and the middle bar scales to zero with opacity 0. Tying the state to aria-pressed keeps keyboard, screen reader, and visual states aligned. If you want an alternate construction without a child span, you can also build three stripes with layered backgrounds as shown on how to make a hamburger menu icon with CSS, though the morph is trickier.

The Plus icon shares the same baseline as the Close icon: two bars, but one stays unrotated and the other is rotated 90 degrees. Center both with translate(-50%, -50%) and use equal lengths to maintain symmetry. For a “thin” style, reduce var(–stroke). For a “rounded” style, add border-radius to the bars.

Advanced Techniques: Adding Animations & Hover Effects

Small motion cues help communicate affordance. Use transforms and opacity for smooth results. Keep timing short and snappy, and respect prefers-reduced-motion. The examples below add a hover nudge to the Arrow, a subtle lens pop for Search, and the Hamburger-to-Close toggle that responds to aria-pressed.

/* CSS */

/* CSS */
/* Shared transitions */
.icon { transition: color 150ms ease; }

/* Arrow Right hover: slide and ease */
.icon--arrow-right { overflow: hidden; }
.icon--arrow-right::before,
.icon--arrow-right::after { transition: transform 180ms ease; }
.icon--arrow-right:hover::before { transform: translate(3px, -50%); }
.icon--arrow-right:hover::after  { transform: translate(3px, -50%); }

/* Search hover: gentle lens scale */
.icon--search::before,
.icon--search::after { transition: transform 180ms ease; }
.icon--search:hover::before { transform: scale(1.04); }
.icon--search:hover::after  { transform: rotate(45deg) scaleX(1.05); }

/* Programmatic toggle (example JS) */
/* Add a click handler to flip aria-pressed:
   button.addEventListener('click', e => {
     const b = e.currentTarget;
     const pressed = b.getAttribute('aria-pressed') === 'true';
     b.setAttribute('aria-pressed', String(!pressed));
     b.setAttribute('aria-label', pressed ? 'Open menu' : 'Close menu');
   });
*/

The Arrow hover moves both the shaft and the head by the same amount, which preserves their alignment. The Search hover scales the lens slightly and extends the handle just a touch, which makes the icon feel reactive without distracting the user. The Hamburger toggle uses aria-pressed to drive the CSS, so no class names are needed.

Accessibility & Performance

Icons live in UI, so they must be accessible and fast. Pure CSS icons do not ship network assets, but you still need descriptive labels and careful motion. The guidance below covers both.

Accessibility

When an icon has no functional meaning on its own and sits next to text, hide it from assistive tech with aria-hidden=”true”. When the icon triggers an action, keep it as a button and use aria-label with a verb, like “Search” or “Open menu”. The Hamburger in this tutorial uses aria-pressed to expose its state. Always sync the label when the state changes, so the control announces “Close menu” when pressed. Respect user motion preferences with the prefers-reduced-motion query already included in the base CSS. Keep focus styles visible; the :focus-visible outline should never be removed.

Performance

These icons draw with borders and transforms, which are trivial for the browser to render. Prefer transforms and opacity for animations, not width/height or box-shadow blurs. Avoid large or nested DOM structures; a single element with pseudo-elements is ideal. If you place many icons inside a scrolling list, keep hover animations lightweight and skip infinite keyframes. Since sizes and strokes use integers, rasterization stays crisp at common zoom levels.

Ship a Hand-Tuned Icon Set with Zero Assets

You built a cohesive icon set that draws entirely with CSS: Search, Hamburger with a morphing Close, Arrow Right, and Plus. The pattern is consistent: a square canvas, currentColor, pseudo-elements, and variables for size and stroke. Add more icons with the same ingredients, such as chevrons, tags, and stars. You now have the tools to design, theme, and animate your own custom icon set without shipping a single image.


Leave a Comment