A crisp, scalable “Play” button is a staple of any media UI. You can ship that icon without images, SVG, or external libraries. This guide walks you through building a pure CSS Play button that scales, themes, and animates cleanly, while staying accessible and fast.
Why a Pure CSS Play Button Matters
Pure CSS keeps the icon in the same stack as your component styles. You do not add network requests for icon sprites or font files. You avoid the color mismatch and hinting problems that come with icon fonts. You keep full control over sizing with CSS custom properties, and you can animate hover and press states with simple transitions. You can also keep the DOM lean by drawing the triangle with a pseudo-element.
Prerequisites
You will get the most out of this guide if you are comfortable with basic layout and positioning. You do not need any build tools or frameworks. Everything runs in the browser with standard CSS.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The component uses a native button element for keyboard and accessibility features. The triangle is drawn with a pseudo-element, so the markup stays minimal. The aria-label gives screen readers a meaningful name without adding visible text. The container below helps you preview a few variants.
<!-- HTML -->
<div class="demo-row">
<button class="play-button" type="button" aria-label="Play"></button>
<button class="play-button play-button--sm" type="button" aria-label="Play (small)"></button>
<button class="play-button play-button--outline" type="button" aria-label="Play (outline)"></button>
</div>
Step 2: The Basic CSS & Styling
Start with a few design tokens to control size, colors, and shadows. The button is a circle with a subtle gradient and soft shadow. We also add a small utility layout class to space the demo buttons. The triangle icon arrives in the next step through a pseudo-element, so this block focuses on the button shell.
/* CSS */
:root {
--size: 80px;
--color-bg: #0b1220;
--color-surface: #111827;
--color-surface-2: #0f172a;
--color-icon: #ffffff;
--color-accent: #22d3ee;
--ring-size: 4px;
--shadow-1: 0 2px 6px rgba(0, 0, 0, 0.35);
--shadow-2: 0 12px 28px rgba(0, 0, 0, 0.25);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
margin: 0;
background: radial-gradient(1200px 600px at 20% 0%, #0d1b2a 0%, var(--color-bg) 60%) no-repeat;
color: #e5e7eb;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
display: grid;
place-items: center;
padding: 32px;
}
.demo-row {
display: flex;
gap: 24px;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.play-button {
--size-current: var(--size);
position: relative;
display: inline-grid;
place-items: center;
width: var(--size-current);
aspect-ratio: 1;
border: 0;
border-radius: 50%;
cursor: pointer;
color: var(--color-icon);
background:
radial-gradient(120% 120% at 30% 25%, rgba(255,255,255,0.14), transparent 45%),
linear-gradient(180deg, var(--color-surface) 0%, var(--color-surface-2) 100%);
box-shadow: var(--shadow-1), var(--shadow-2);
-webkit-tap-highlight-color: transparent;
transition: transform 160ms ease, box-shadow 160ms ease, background 240ms ease;
}
.play-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0,0,0,0.4), 0 16px 36px rgba(0,0,0,0.28);
}
.play-button:active {
transform: translateY(0) scale(0.97);
}
.play-button:focus-visible {
outline: none;
box-shadow:
0 0 0 var(--ring-size) color-mix(in oklab, var(--color-accent) 40%, transparent),
var(--shadow-1), var(--shadow-2);
}
/* Size variants */
.play-button--sm { --size-current: 56px; }
.play-button--outline {
background: transparent;
border: 2px solid color-mix(in oklab, var(--color-icon), transparent 20%);
box-shadow: none;
}
.play-button--outline:hover {
background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
}
Advanced Tip: Put key dimensions and colors in CSS custom properties. You can now theme the button per container by overriding –size, –color-icon, or –color-accent without touching the component CSS.
Step 3: Building the Triangle Icon
The Play glyph is a right-pointing triangle drawn with borders on a zero-size pseudo-element. This keeps the DOM clean and gives you full control over size and color with currentColor. The triangle sits on the button and remains crisp at any size.
/* CSS */
.play-button::before {
content: "";
position: absolute;
width: 0;
height: 0;
/* Triangle built with borders */
border-style: solid;
/* Top, Right, Bottom, Left border widths */
border-width: calc(var(--size-current) * 0.19) 0 calc(var(--size-current) * 0.19) calc(var(--size-current) * 0.28);
/* Make only the left border visible to point right */
border-color: transparent transparent transparent currentColor;
left: 50%;
top: 50%;
/* Offset to visually center the triangular mass */
transform: translate(-34%, -50%);
/* Subtle lift for contrast over dark fills */
filter: drop-shadow(0 1px 0 rgba(255,255,255,0.12)) drop-shadow(0 1px 8px rgba(0,0,0,0.25));
}
/* Outline variant icon keeps the same shape, just a tad thicker for readability */
.play-button--outline::before {
border-width: calc(var(--size-current) * 0.21) 0 calc(var(--size-current) * 0.21) calc(var(--size-current) * 0.30);
}
How This Works (Code Breakdown)
The triangle uses the classic border trick on a zero-size box. By setting border-style: solid and giving only the left border a color, the shape becomes a right-pointing triangle. The size of the triangle is controlled through border-width. Using calc with –size-current ties the icon size to the button size, so the triangle always feels balanced at small and large scales.
The pseudo-element is absolutely positioned with left: 50% and top: 50%. The transform translates the triangle so its centroid sits in the visual center of the circle. A perfect geometric center is not the same as a visual center for a triangle, so a horizontal offset of about 34% gives a more balanced look. You can nudge that value to match your brand weight.
Using currentColor for the triangle color keeps the icon in sync with the button text color. This is a robust way to theme icons without extra selectors. If you want a different icon color on hover, change color on .play-button:hover and the triangle will follow.
If you want more detail on the technique, this triangle follows the same approach as the guide on how to make a right triangle with CSS. The circular button shape comes from border-radius: 50%, the same core idea covered in how to make a circle with CSS. The button keeps a 1:1 ratio, which mirrors the base pattern in how to make a square with CSS.
Step 4: Adding Interaction States and Variants
A Play button needs clear hover, active, and focus states. The code below adds a soft hover lift, a pressed scale, and a ripple effect. It also introduces a color token for a branded theme. You can switch themes by overriding the variable in a parent container.
/* CSS */
.play-button {
/* Keep prior styles; we add a ripple layer */
isolation: isolate; /* So the ripple stays under the icon but above the background */
}
.play-button::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
background:
radial-gradient(closest-side, color-mix(in oklab, var(--color-icon) 60%, transparent), transparent 70%);
transform: scale(0);
opacity: 0;
transition: transform 300ms ease, opacity 450ms ease;
z-index: -1; /* Let the icon sit above */
}
.play-button:active::after {
transform: scale(1.45);
opacity: 0.35;
transition-duration: 500ms, 600ms;
}
/* Focus ring tuned for keyboard use */
.play-button:focus-visible {
box-shadow:
0 0 0 var(--ring-size) color-mix(in oklab, var(--color-accent) 55%, transparent),
var(--shadow-1), var(--shadow-2);
}
/* Themed variant example */
.theme-lime .play-button {
--color-accent: #84cc16;
--color-icon: #eaffcf;
background:
radial-gradient(120% 120% at 30% 25%, rgba(255,255,255,0.16), transparent 45%),
linear-gradient(180deg, #204513 0%, #16380d 100%);
}
How This Works (Code Breakdown)
The :after pseudo-element draws a radial-gradient that scales from the center to create a ripple. Setting isolation: isolate on the button starts a new stacking context, which prevents the ripple from blending with content outside the button. The ripple does not intercept clicks because pointer-events is off. The z-index is negative so the ripple sits below the triangle while staying inside the button.
The hover and active transforms use short, snappy durations to keep the control feeling responsive. The pressed state scales down a bit to signal click. The focus-visible rule uses a thick, soft ring that remains visible on dark backgrounds. Keyboard users get a clear target without causing layout shift.
The theme-lime example shows how to swap colors by redefining –color-accent and –color-icon on a parent. This keeps the component portable across brands. No extra markup or classes are needed inside the button.
Advanced Techniques: Animations & Hover Effects
You can add a breathing pulse for attention or a hover sheen that sweeps across the surface. The pulse below is subtle and respects reduced motion. Avoid endless hard flashing on key controls. A gentle hint draws the eye without stealing focus from content.
/* CSS */
@keyframes playPulse {
0%, 100% { box-shadow: var(--shadow-1), var(--shadow-2); }
50% { box-shadow: 0 4px 10px rgba(0,0,0,0.42), 0 18px 40px rgba(0,0,0,0.30); }
}
.play-button.is-pulsing {
animation: playPulse 2200ms ease-in-out infinite;
}
/* Hover sheen using a conic-gradient sweep */
.play-button::before,
.play-button::after { will-change: transform, opacity; } /* Micro-optimizations for the animated layers only */
.play-button:hover {
background:
radial-gradient(120% 120% at 30% 25%, rgba(255,255,255,0.18), transparent 45%),
linear-gradient(180deg, var(--color-surface) 0%, var(--color-surface-2) 100%);
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.play-button,
.play-button::after {
transition: none;
}
.play-button.is-pulsing {
animation: none;
}
}
The pulse leans on box-shadow changes rather than scaling the entire button. This reduces layout jitter and keeps the icon readable. The reduced-motion query turns off nonessential animation for users who prefer less movement. Keep the hover duration short. CSS should hint at interactivity, not distract.
Accessibility & Performance
A Play button is more than a shape. It is an interactive control that must work with the keyboard, screen readers, and touch. The button element gives you most of that for free. You still need to label it, handle focus, and keep the motion friendly.
Accessibility
Use a native button with type=”button”. Add aria-label=”Play” to give the control a clear name. The triangle pseudo-element is decorative, so it does not need a separate label. The focus-visible outline helps keyboard users find the target. Match or exceed a 3:1 contrast ratio for the icon against the surface. If you place the button on a busy image, crank up –color-icon or add a stronger inner shadow for separation.
Respect the prefers-reduced-motion setting. The code above removes the pulse and reduces transitions. Keep the click target large on touch screens. The default size of 80px lands well inside common mobile hit area guidelines. You can drop to 56px small with the .play-button–sm class while keeping a comfortable area.
Performance
The component uses a single element with two pseudo-elements. The triangle is a border on a 0×0 box, which is very light. Transforms and opacity changes stay on the compositor thread in modern browsers. Box-shadow animation costs more than transforms, so use the pulse sparingly on lists of many buttons. You do not need will-change globally. Keep it limited to the layers that move, like the ripple pseudo-element.
Ship a Shape That Works Everywhere
You now have a pure CSS Play button that scales, themes, and animates without images or SVG. The triangle is built with borders, the button is a circle, and the entire control fits in one element. Extend the pattern to build pause, stop, and record icons with the same approach. Now you have the tools to build your own custom media icon set with CSS alone.