Creating a Star Rating System with CSS

Introduction

Let’s build a crisp, scalable location pin (map marker) using only CSS. No SVG. No images. By the end of this tutorial, you’ll have a compact component that renders cleanly at any size, supports theming with CSS variables, and can animate for subtle feedback. We’ll compose the pin from a circular head and a tapered tail, then add a soft base shadow and an optional pulse effect for attention.

Why a Pure CSS Location Pin Matters

Icons built with CSS are fast to theme, easy to inline, and require no network requests. You can drive size and color with custom properties and let the browser handle the rest. Compared to icon fonts, you avoid font loading and accessibility quirks. Compared to SVG, you can keep everything inside your stylesheet and reuse the same class across your app. This is ideal for toggles, small map previews, and skeleton states where you need a placeholder icon before real data appears.

Prerequisites

You don’t need much to follow along. We use a small HTML structure and a handful of CSS techniques: custom properties, pseudo-elements, and border triangles.

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

Step 1 – The HTML Structure

The component uses a single wrapper for the pin, an inner dot for the center of the marker, and a separate element for the soft base shadow. Keep it minimal and let CSS do the drawing. Here’s the complete HTML we’ll reference in the rest of the tutorial.

<div class="pin" role="img" aria-label="Location">
  <span class="pin__inner"></span>
  <span class="pin__shadow" aria-hidden="true"></span>
</div>

Step 2 – The Basic CSS & Styling

Define theme variables for size, color, and shadow. The wrapper sets up a positioning context and handles responsive sizing. We use calc() to keep the tail proportionate to the head, which keeps the silhouette balanced at any scale.

/* CSS */

:root {
  --pin-size: 64px;           /* head diameter */
  --pin-color: #e63946;       /* marker color */
  --pin-accent: #ffffff;      /* inner dot */
  --pin-shadow: rgba(0, 0, 0, 0.18);
}

.pin {
  /* layout */
  position: relative;
  display: inline-block;
  width: var(--pin-size);
  height: calc(var(--pin-size) * 1.4); /* space for the tail */
  filter: drop-shadow(0 6px 12px var(--pin-shadow));
}

/* center dot */
.pin__inner {
  position: absolute;
  left: 50%;
  top: calc(var(--pin-size) * 0.23);
  width: calc(var(--pin-size) * 0.38);
  height: calc(var(--pin-size) * 0.38);
  transform: translate(-50%, 0);
  background: var(--pin-accent);
  border-radius: 50%;
  z-index: 2;
}

/* base shadow disc (under the tip) */
.pin__shadow {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: calc(var(--pin-size) * 0.6);
  height: calc(var(--pin-size) * 0.18);
  transform: translateX(-50%);
  background: radial-gradient(closest-side, rgba(0,0,0,0.25), transparent 70%);
  border-radius: 50%;
  z-index: 0;
}

Pro Tip: Keep all geometry tied to –pin-size with calc(). That makes theming trivial and prevents visual drift when you scale the component up or down.


Default


Small


Large

Step 3 – Building the Head and Tail

The pin’s head is a circle made on the ::before pseudo-element. The tail is a downward-facing triangle made on ::after using the border trick. This keeps the HTML clean and everything positioned within a single wrapper.

/* CSS */

.pin {
  position: relative;
  display: inline-block;
  width: var(--pin-size);
  height: calc(var(--pin-size) * 1.4);
  filter: drop-shadow(0 6px 12px var(--pin-shadow));
}

.pin::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 0;
  transform: translateX(-50%);
  width: var(--pin-size);
  height: var(--pin-size);
  background: var(--pin-color);
  border-radius: 50%; /* circle head */
  z-index: 1;
}

.pin::after {
  content: "";
  position: absolute;
  left: 50%;
  top: calc(var(--pin-size) * 0.56);
  transform: translateX(-50%);
  /* border triangle for the tail */
  border-left: calc(var(--pin-size) * 0.18) solid transparent;
  border-right: calc(var(--pin-size) * 0.18) solid transparent;
  border-top: calc(var(--pin-size) * 0.68) solid var(--pin-color);
  z-index: 1;
}

How This Works (Code Breakdown)

We give the wrapper position: relative so its pseudo-elements can anchor to it. The height allocates space for the tapered tail, which projects below the circle. The head uses border-radius: 50% to form a perfect circle. If you want a deeper refresher on drawing a circle with CSS, see how to make a circle with CSS.

The tail uses the border triangle technique: two transparent side borders and one colored top border. That creates a clean downward point with no extra markup. If you want to master the geometry of this trick for other UI pointers and tooltips, check out how to make a triangle down with CSS.

The inner dot sits on a real element (.pin__inner) so it can receive its own shadow and transitions. It aligns with left: 50% and a translateX(-50%) to keep it perfectly centered.

Step 4 – Adding the Soft Base Shadow

Pins look grounded when you anchor them with a soft, elliptical shadow. This creates depth without heavy blur filters. We place a small ellipse at the bottom center of the wrapper. A radial gradient softens the edge so it blends nicely on any background.

/* CSS */

.pin__shadow {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: calc(var(--pin-size) * 0.6);
  height: calc(var(--pin-size) * 0.18);
  transform: translateX(-50%);
  background: radial-gradient(closest-side, rgba(0,0,0,0.25), transparent 70%);
  border-radius: 50%; /* ellipse on a short axis */
}

How This Works (Code Breakdown)

An ellipse is just a rounded rectangle with different width and height. With border-radius: 50% applied to a wide, short box, you get a neat oval. If you want a dedicated guide on shape math and border-radius behavior, read how to make an ellipse with CSS. The gradient uses closest-side to feather the shadow from center to transparent edge, which avoids a hard circle ring under the tip.

Advanced Techniques: Hover, Focus, and Pulse

Let’s add a soft hover lift, a focus ring for keyboard users, and an optional pulse animation that draws attention to a location. The pulse is driven by a ring expanding from the center of the head. Respect user motion preferences while you do it.

/* CSS */

/* gentle lift and accent on hover/focus */
.pin {
  transition: transform 180ms ease, filter 180ms ease;
}
.pin:hover,
.pin:focus-visible {
  transform: translateY(-2px);
  filter: drop-shadow(0 10px 14px var(--pin-shadow));
}
.pin:focus-visible .pin__inner {
  box-shadow: 0 0 0 3px color-mix(in oklab, var(--pin-color), white 65%);
}

/* pulse ring */
.pin::before {
  /* existing circle styles plus ring setup */
  position: absolute;
  /* ...existing properties... */
  box-shadow: 0 0 0 0 color-mix(in oklab, var(--pin-color), white 50%);
  animation: pin-pulse 2s ease-out infinite;
}

/* reduce motion when requested */
@media (prefers-reduced-motion: reduce) {
  .pin { transition: none; }
  .pin::before { animation: none; }
}

@keyframes pin-pulse {
  0%   { box-shadow: 0 0 0 0 color-mix(in oklab, var(--pin-color), white 40%); }
  70%  { box-shadow: 0 0 0 14px transparent; }
  100% { box-shadow: 0 0 0 0 transparent; }
}



Accessibility & Performance

Icons carry meaning, so treat them with care. When the pin is decorative, add aria-hidden=”true” to the wrapper or the nearest container, and remove the ARIA label. When the pin conveys a status or selection, supply a clear aria-label like “Selected location” or use an adjacent text label that describes the item. If the pin is interactive, give it a focus outline, keyboard support, and a large enough hit area.

Accessibility

Motion should support attention, not distract or trigger. Wrap your animation in a prefers-reduced-motion query to respect user preferences. Keep color contrast strong for the central dot if it communicates state. If you toggle colors for state (e.g., red vs. green pins), back it with a text label so colorblind users get the same signal.

Performance

This component paints quickly. Border triangles and border-radius circles are inexpensive for browsers. The drop-shadow filter is GPU-accelerated in modern engines and is usually faster than a large blur box-shadow. The pulse uses a box-shadow expansion, which is moderate in cost. Keep the pulse radius modest and avoid stacking many pins with simultaneous animations. If you plan to render hundreds of pins, consider disabling animation or using an intersection observer to start/stop effects only when pins are on screen.

Final Thoughts

You now have a flexible, pure CSS location pin that scales with a single variable, supports theming, and includes optional motion. The same circle and triangle techniques power many UI elements, so keep them handy as you build out tooltips, badges, and map overlays.

Leave a Comment