Introduction
A “Play” button is one of the most recognizable UI elements on the web. You don’t need an image or SVG to build it. In this tutorial, you’ll build a crisp, scalable, pure CSS Play button that renders a circle with a right‑facing triangle inside. You’ll learn two triangle techniques (border trick and clip-path), how to size the icon with custom properties, how to add hover and focus states, and how to ship it accessibly.
By the end, you’ll have a production‑ready component you can drop into any site or design system.
Why a Pure CSS Play Button Matters
– No asset pipeline: No SVG files to load, no icon font subset to manage. Everything is in your stylesheet.
– Themeable: The icon inherits color, responds to CSS variables, and adapts to dark mode instantly.
– Scalable: Drawn by the browser at any size, with pixel‑sharp edges on high‑DPI screens.
– Flexible: Switch from a circle to a rounded square, or from a triangle to a pause icon, using CSS only.
If you haven’t built shapes with CSS before, you’ll see the same primitives used across icons and components. For example, a Play button combines a circle and a right‑facing triangle. We have dedicated step‑by‑step shape guides for both:
– Circle: https://css3shapes.com/shapes/how-to-make-a-circle-with-css/
– Right triangle: https://css3shapes.com/shapes/how-to-make-a-triangle-right-with-css/
Prerequisites
– Basic HTML
– CSS custom properties
– CSS pseudo‑elements (::before / ::after)
– Positioning (relative/absolute)
– Comfort with the classic triangle “border trick” and modern clip-path
Step 1 – The HTML Structure
We’ll start with a single button element. It’s semantic, keyboard‑focusable, and easy to style. The component doesn’t require inner text because the icon is decorative; we’ll use an accessible label.
<!-- Demo wrapper (optional) -->
<div class="demo">
  <button class="play-button" type="button" aria-label="Play video"></button>
  <!-- A large variant using a modifier class -->
  <button class="play-button play-button--lg" type="button" aria-label="Play video"></button>
  <!-- A compact variant -->
  <button class="play-button play-button--sm" type="button" aria-label="Play video"></button>
</div>Structure notes:
– One element keeps DOM light. The triangle will be drawn with a pseudo‑element.
– aria-label announces the action to screen readers. Swap the string based on your context (“Play audio,” “Play trailer,” etc.).
– Modifier classes scale the component with different CSS variable values.
Step 2 – The Basic CSS & Styling
Let’s set up variables for size and colors, provide a simple demo layout, and define the circle container.
/* Base demo styles (not required in production) */
:root {
  --play-size: 64px;       /* default size */
  --play-bg: #0f172a;      /* button background */
  --play-fg: #ffffff;      /* icon color */
  --play-ring: #22d3ee;    /* focus ring */
  --play-hover: #1e293b;   /* hover background */
  --play-shadow: rgba(2, 6, 23, 0.45);
}
.demo {
  display: grid;
  gap: 1.25rem;
  grid-auto-flow: column;
  justify-content: start;
  align-items: center;
  padding: 2rem;
  background: linear-gradient(135deg, #0b1020, #0a0f1e);
}
*,
*::before,
*::after {
  box-sizing: border-box;
}
.play-button {
  /* sizing and layout */
  --size: var(--play-size);
  inline-size: var(--size);
  block-size: var(--size);
  aspect-ratio: 1; /* keeps a square that becomes a circle via border-radius */
  display: inline-grid;
  place-items: center;
  /* circle container */
  border: none;
  border-radius: 50%;
  background: var(--play-bg);
  color: var(--play-fg);
  box-shadow:
    0 4px 14px var(--play-shadow),
    inset 0 0 0 1px rgba(255,255,255,0.06);
  cursor: pointer;
  /* animation baseline */
  transition:
    background-color 150ms ease,
    transform 120ms ease,
    box-shadow 150ms ease;
}
.play-button:hover {
  background: var(--play-hover);
}
.play-button:active {
  transform: scale(0.98);
}
.play-button:focus {
  outline: none; /* we will draw a custom ring on :focus-visible */
}
.play-button:focus-visible {
  box-shadow:
    0 0 0 3px color-mix(in srgb, var(--play-ring) 40%, transparent),
    0 0 0 6px color-mix(in srgb, var(--play-ring) 24%, transparent),
    0 4px 14px var(--play-shadow),
    inset 0 0 0 1px rgba(255,255,255,0.06);
}
/* Size modifiers */
.play-button--lg { --size: 96px; }
.play-button--sm { --size: 48px; }Step 3 – Building the Outer Shape (the Circle)
We already styled the circle container in Step 2. Here we’ll refine the circle with a subtle radial highlight and add an “aura” ring on hover to draw attention without heavy motion.
/* Circle polish: subtle gloss and aura ring on hover */
.play-button {
  background:
    radial-gradient(120% 120% at 30% 20%, rgba(255,255,255,0.08), transparent 45%),
    var(--play-bg);
}
.play-button::before {
  /* hover aura ring, initially invisible */
  content: "";
  position: absolute;
  inline-size: calc(var(--size) + 8px);
  block-size: calc(var(--size) + 8px);
  border-radius: 50%;
  box-shadow: 0 0 0 0 color-mix(in srgb, var(--play-ring) 8%, transparent);
  transition: box-shadow 200ms ease;
}
.play-button:hover::before {
  /* faint glowing perimeter */
  box-shadow: 0 0 0 6px color-mix(in srgb, var(--play-ring) 18%, transparent);
}How This Works (Code Breakdown)
– background: radial-gradient(…), var(–play-bg): The circle has a soft highlight from a radial gradient layered over the solid background. This adds depth while remaining flat‑color friendly.
– aspect-ratio: 1 with border-radius: 50% yields a perfect circle. For more detail and alternatives, see our circle guide: https://css3shapes.com/shapes/how-to-make-a-circle-with-css/
– ::before aura ring: A positioned pseudo‑element sized slightly larger than the button. We animate its box‑shadow opacity and spread to create a glow without moving pixels around the main element.
– Focus ring: focus-visible keeps the ring for keyboard users only. The layered box‑shadows provide an accessible, high‑contrast outline.
Step 4 – Building the Icon (the Triangle)
Now we’ll draw the right‑facing triangle. We’ll start with the classic “border trick,” then provide a clip-path variant you can swap in.
/* Triangle via the border trick (default) */
.play-button::after {
  content: "";
  /* Place the triangle in the button center */
  position: relative;
  display: block;
  /* The triangle itself */
  inline-size: 0;
  block-size: 0;
  border-style: solid;
  /* These three sides are transparent; one side is colored to form the triangle */
  border-width:
    calc(var(--size) * 0.20)        /* top */
    0                                /* right */
    calc(var(--size) * 0.20)        /* bottom */
    calc(var(--size) * 0.30);       /* left */
  border-color:
    transparent
    transparent
    transparent
    currentColor;
  /*
    Nudge the triangle slightly right so it looks optically centered.
    Mathematically centered triangles appear off because of their point.
  */
  transform: translateX(calc(var(--size) * 0.04));
}
/* Optional: clip-path variant (swap class to enable) */
.play-button--clip::after {
  /* Replace border trick with a polygon triangle for crisper scaling */
  inline-size: calc(var(--size) * 0.50);
  block-size: calc(var(--size) * 0.50);
  background: currentColor;
  /* center it */
  position: relative;
  transform: translateX(calc(var(--size) * 0.04));
  /*
    Triangle polygon points:
    left tip (0% 0%), right mid (100% 50%), left bottom tip (0% 100%),
    then back to left tip to close.
  */
  -webkit-clip-path: polygon(0 0, 100% 50%, 0 100%, 0 0);
          clip-path: polygon(0 0, 100% 50%, 0 100%, 0 0);
  border: none;
}How This Works (Code Breakdown)
– ::after draws the icon inside the button. Using a pseudo‑element avoids extra markup.
– border trick triangle:
  – inline-size: 0; block-size: 0; is the core of this technique. The element itself has no dimension; the “triangle” appears from its borders.
  – border-style: solid; makes the borders render as shapes.
  – border-width: We set top and bottom to the same size, right to zero, and left to the widest value. The colored left border becomes the triangle face, and the transparent top/bottom edges form the tapered sides.
  – border-color: Assign currentColor to the active border and transparent to the other three. currentColor inherits from the button’s color, so the triangle matches your theme automatically.
  – transform: translateX(…): Slight optical centering. Triangles feel left‑heavy when truly centered because of the pointy tip’s visual weight.
  – Learn more about border triangles and sizing techniques here: https://css3shapes.com/shapes/how-to-make-a-triangle-right-with-css/
– clip-path variant:
  – background + clip-path: polygon(…) cuts a right‑facing triangle out of a rectangle. This scales cleanly at large sizes and under transforms.
  – -webkit-clip-path: Prefixed property for old WebKit builds. Modern browsers support unprefixed clip-path.
  – Consider clip-path when you need very large icons, sharp scaling transitions, or advanced morphs.
If this is your first time creating triangles or you want alternative directions (up, down, left, equilateral, right‑angled), check the triangle shape library:
– Up: https://css3shapes.com/shapes/how-to-make-a-triangle-up-with-css/
– Down: https://css3shapes.com/shapes/how-to-make-a-triangle-down-with-css/
– Left: https://css3shapes.com/shapes/how-to-make-a-triangle-left-with-css/
– Right: https://css3shapes.com/shapes/how-to-make-a-triangle-right-with-css/
– Equilateral: https://css3shapes.com/shapes/how-to-make-an-equilateral-triangle-with-css/
– Right‑angled: https://css3shapes.com/shapes/how-to-make-a-right-angled-triangle-with-css/
Advanced Techniques: Adding Animations & Hover Effects
Here are practical enhancements you can add without extra markup.
1) Soft scale and color transition
– Already included: transform on :active, background on :hover.
– Tweak easing to match your design language.
2) Ripple ring on hover/focus
– Use the existing ::before as a glow ring, and add a “pulse” keyframe for a gentle signal.
– Respect users with reduced motion preferences.
3) Play/Pause toggle with aria-pressed
– Swap the triangle for “pause bars” using a single pseudo‑element and a linear-gradient.
– No markup change; just toggle aria-pressed in JS.
Code:
/* Motion-friendly pulse for the aura ring */
@keyframes pulse-ring {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--play-ring) 12%, transparent); }
  70%  { box-shadow: 0 0 0 10px color-mix(in srgb, var(--play-ring) 0%, transparent); }
  100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--play-ring) 0%, transparent); }
}
.play-button:hover::before,
.play-button:focus-visible::before {
  animation: pulse-ring 1.2s ease-out;
}
/* Reduced motion: disable pulsing */
@media (prefers-reduced-motion: reduce) {
  .play-button,
  .play-button::before,
  .play-button::after {
    transition: none !important;
    animation: none !important;
  }
}
/* Play/Pause toggle using aria-pressed
   - aria-pressed="true" represents the "Pause" state here.
   - We replace the triangle with pause bars using a gradient. */
.play-button[aria-pressed="true"]::after {
  /* full reset from triangle styles */
  inline-size: calc(var(--size) * 0.52);
  block-size: calc(var(--size) * 0.52);
  border: none;
  transform: none;
  /*
    Create two vertical bars with a single gradient:
    - left bar: 0-40%
    - gap:     40-60%
    - right bar: 60-100%
  */
  background:
    linear-gradient(90deg,
      currentColor 0%,
      currentColor 40%,
      transparent 40%,
      transparent 60%,
      currentColor 60%,
      currentColor 100%);
  border-radius: 2px; /* soften the bar edges */
}
/* Optional: off-center tweak so pause bars feel centered */
.play-button[aria-pressed="true"]::after {
  transform: translateX(1px);
}Notes:
– The pulse animation animates a shadow, not layout. It’s cheap and looks refined.
– The pause icon uses a gradient rectangle to avoid a second pseudo‑element. If you prefer literal rectangles, build them with ::before and ::after and follow the rectangle shape techniques here: https://css3shapes.com/shapes/how-to-make-a-rectangle-with-css/
– Want a different glyph? A chevron gives a “next” vibe and is easy to draw with CSS: https://css3shapes.com/shapes/how-to-make-a-chevron-right-with-css/
– Want to enclose the icon in a rounded square or rectangle instead of a circle? See square and rectangle shape guides:
  – Square: https://css3shapes.com/shapes/how-to-make-a-square-with-css/
  – Rectangle: https://css3shapes.com/shapes/how-to-make-a-rectangle-with-css/
Accessibility & Performance
Accessibility
– Use a semantic button element. It’s keyboard focusable and announces correctly to assistive tech.
– aria-label: Provide a clear label, e.g., “Play video.” When you toggle to pause, switch to “Pause video” and update aria-pressed to reflect state.
– Focus indicator: Keep a visible, high‑contrast outline on :focus-visible. Avoid removing focus outlines without a replacement.
– Hit target: Keep the button at least 44×44 px for touch. Our default 64 px meets this comfortably.
– Reduced motion: Respect prefers-reduced-motion. Disable non‑essential animations so the UI stays comfortable.
Performance
– The border triangle is extremely cheap to render. It’s a few painted edges.
– clip-path is GPU‑accelerated in modern browsers. Static usage is fast. Avoid animating clip-path on every frame; prefer transforms or opacity for transitions.
– Shadows: Inset and small drop shadows are fine. Avoid animating large, blurred shadows repeatedly.
– Repaints: The component animates transforms and shadows, not layout. Layout thrash is avoided.
Crispness and scaling
– The border triangle can look slightly “fuzzy” at certain fractional pixel sizes. If you scale the icon with transforms or need very large sizes (200 px and up), the clip-path variant provides crisp edges.
– The circle itself is always clean with border-radius: 50%. For other circular techniques and fallbacks, see the circle guide: https://css3shapes.com/shapes/how-to-make-a-circle-with-css/
Keyboard and screen reader behavior
– Space/Enter should trigger the button. The base button element provides this out of the box.
– If your Play button actually controls media, wire it to your player’s state and synchronize aria-pressed and aria-label with that state.
Example state sync (JS sketch):
– On play: aria-label=”Pause video”, aria-pressed=”true”.
– On pause: aria-label=”Play video”, aria-pressed=”false”.
This keeps the visual icon and the accessible state aligned.
Color contrast
– The triangle uses currentColor against the button background. Ensure contrast ratio meets WCAG (at least 3:1 for UI icons against their background). Dark backgrounds with white icons work well; adjust variables for your theme.
Final Thoughts
You built a pure CSS Play button with a circle container and a right‑facing triangle, sized and themed with CSS custom properties. You learned both border‑based and clip‑path triangles, added motion that respects system preferences, and shipped it with an accessible structure and keyboard support. Now you have the techniques to compose your own CSS icon set from primitives like circles, triangles, rectangles, and chevrons.
