Introduction
Map pins don’t need images or SVGs. In this tutorial you’ll build a pure CSS map pin with a tooltip and a subtle pulse, using only HTML and CSS. You’ll compose the pin from two classic shapes, the circle and a triangle, and wire up a practical hover/focus interaction that works with a mouse, keyboard, and touch.
Why a Pure CSS Map Pin Matters
Component libraries often ship pins as images or SVGs. That adds assets to manage, makes them harder to theme, and sometimes leads to blurry rendering at odd sizes. With pure CSS, the pin scales crisply at any size, adapts to dark or light backgrounds through CSS variables, and ships as a few lightweight rules. You also gain tight control over hover and focus states without JavaScript.
Prerequisites
You don’t need a build step or a framework for this project. A text editor and a browser are enough. Familiarity with the basics helps:
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1 – The HTML Structure
The pin is a single interactive element placed on a “map” container. The circular head and the triangular point are semantic-free spans marked as decorative. The tooltip lives inside the pin and is associated using aria-describedby for accessible labeling.
<div class="map" role="application" aria-label="Locations map">
  <button class="pin" aria-describedby="tt1" style="--x:60%; --y:55%;">
    <span class="pin__head" aria-hidden="true"></span>
    <span class="pin__point" aria-hidden="true"></span>
    <span class="pin__ring" aria-hidden="true"></span>
    <span class="pin__tooltip" role="tooltip" id="tt1">
      Downtown Office
    </span>
  </button>
</div>
The .map acts as the positioning context. Each .pin is a focusable button to make keyboard access natural. The inline style sets a custom position via CSS variables (–x and –y), so you can drop multiple pins at different coordinates without extra classes.
Step 2 – The Basic CSS & Styling
The base styles define theme variables, a simple grid-like map background, and the positioning model for pins. This foundation keeps the later shape rules focused and readable.
/* CSS */
:root {
  --pin-color: #4f46e5;   /* Indigo */
  --pin-shadow: 0 6px 16px rgba(0,0,0,.18);
  --map-bg: #f6f8ff;
  --map-grid: rgba(79,70,229,.08);
  --tooltip-bg: #ffffff;
  --tooltip-color: #0f172a;
  --tooltip-shadow: 0 10px 24px rgba(2,6,23,.18);
  --size: 28px;           /* Pin head size */
}
/* Page/demo convenience, not required for your app */
body {
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
  color: #0f172a;
  background: #fff;
  margin: 0;
  padding: 2rem;
}
.map {
  position: relative;
  inline-size: min(720px, 100%);
  block-size: 380px;
  margin: 0 auto;
  border-radius: 16px;
  background:
    linear-gradient(var(--map-grid) 1px, transparent 1px) 0 0/32px 32px,
    linear-gradient(90deg, var(--map-grid) 1px, transparent 1px) 0 0/32px 32px,
    var(--map-bg);
  box-shadow: 0 12px 40px rgba(2,6,23,.06);
  overflow: hidden;
}
/* Pin positioning based on custom properties so each pin can be placed uniquely */
.pin {
  position: absolute;
  top: var(--y, 50%);
  left: var(--x, 50%);
  transform: translate(-50%, -100%); /* tip points to the coordinate */
  display: grid;
  place-items: center;
  gap: 2px;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
.pin:focus-visible {
  outline: 2px solid color-mix(in oklab, var(--pin-color), white 20%);
  outline-offset: 6px;
}
Pro Tip: The variables –x and –y make each pin’s position data-driven. You can source them from a CMS or dataset and drop them inline or via a style attribute. Centralizing color and size as variables makes theme and scale changes trivial.
Step 3 – Building the Pin Head and Point
The head is a circle with a soft highlight. The point is a small downward-facing triangle that “anchors” the pin to the map coordinate. Both sit in a mini grid so they line up cleanly.
/* CSS */
.pin__head {
  inline-size: var(--size);
  block-size: var(--size);
  border-radius: 50%;
  background: radial-gradient(circle at 30% 30%, #8b92ff 15%, var(--pin-color) 60%);
  box-shadow:
    0 0 0 2px #fff,        /* subtle outline for contrast on busy maps */
    var(--pin-shadow);
}
.pin__point {
  inline-size: 0;
  block-size: 0;
  border-left: calc(var(--size) * .25) solid transparent;
  border-right: calc(var(--size) * .25) solid transparent;
  border-top: calc(var(--size) * .35) solid var(--pin-color); /* triangle down */
  filter: drop-shadow(0 2px 2px rgba(0,0,0,.15));
  transform: translateY(-1px); /* tuck under the head */
}
How This Works (Code Breakdown)
For the head, a fixed inline-size and block-size with border-radius: 50% gives you a perfect circle. If you haven’t built one before, this is the same approach used to make a circle with CSS. The radial-gradient provides a highlight toward the upper-left, which adds depth. A 2px white outline ensures the pin reads well on darker or patterned maps.
The point uses the classic border triangle trick. With width and height set to zero, only the borders render. Setting border-left and border-right to transparent shapes the sides, and border-top defines the visible triangle. That produces a downward-pointing pointer, the same technique you’d use to make a triangle down with CSS. The small drop-shadow separates the point from the map background. The translateY tweak nudges the triangle under the circle so the two shapes feel like a single teardrop.
The .pin container uses CSS grid with place-items: center to keep the head and point aligned along a vertical axis. The transform on the .pin translates the whole assembly so the “tip” marks the position, not the center of the head.
Step 4 – Building the Tooltip
The tooltip is an absolutely positioned label that appears above the pin on hover or keyboard focus. A small arrow connects the tooltip to the pin using another border triangle. You’ll keep the tooltip in the DOM and toggle visibility with pure CSS states.
/* CSS */
.pin__tooltip {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(6px);
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
  background: var(--tooltip-bg);
  color: var(--tooltip-color);
  font-size: 14px;
  line-height: 1;
  padding: 10px 12px;
  border-radius: 10px;
  box-shadow: var(--tooltip-shadow);
  transition: opacity .18s ease, transform .18s ease;
  z-index: 1;
}
.pin__tooltip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  inline-size: 0;
  block-size: 0;
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
  border-top: 8px solid var(--tooltip-bg); /* triangle down from the tooltip */
}
/* Reveal on hover/focus */
.pin:hover .pin__tooltip,
.pin:focus-visible .pin__tooltip {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
  pointer-events: auto;
}
How This Works (Code Breakdown)
Positioning the tooltip above the pin uses bottom: calc(100% + 10px) and left: 50% with a translateX(-50%) to center it. Starting it slightly translated on the Y-axis and at zero opacity creates a short “lift” when it becomes visible. The reveal uses :hover and :focus-visible on the .pin so mouse and keyboard users get the same effect.
The arrow is a small triangle created with borders on a zero-sized pseudo-element. Placing it at top: 100% anchors it to the bottom edge of the tooltip. If you want more design options for speech bubbles, see this pattern applied in the tooltip shape guide.
Advanced Techniques: Animations & Hover Effects
A gentle pulse draws attention to the pin without being distracting. The pulse is a ring that scales and fades behind the head. A micro-bounce on hover gives tactile feedback. Respect the user’s motion settings with a reduced-motion media query.
/* CSS */
.pin {
  /* create a stacking context for clean layer ordering */
  isolation: isolate;
}
/* Pulse ring */
.pin__ring {
  position: absolute;
  inset: auto;
  bottom: -6px; /* roughly around the tip */
  inline-size: calc(var(--size) * .8);
  block-size: calc(var(--size) * .8);
  border-radius: 50%;
  border: 2px solid color-mix(in oklab, var(--pin-color), white 35%);
  opacity: .6;
  transform: translateY(8px);
  animation: pin-pulse 2s ease-out infinite;
  z-index: -1;
}
/* Micro-interaction on hover */
.pin:hover .pin__head {
  transform: translateY(-2px);
  transition: transform .15s ease;
}
.pin:hover .pin__point {
  transform: translateY(-2px);
  transition: transform .15s ease;
}
@keyframes pin-pulse {
  0%   { transform: translateY(8px) scale(.5); opacity: .6; }
  70%  { transform: translateY(8px) scale(1.25); opacity: 0; }
  100% { transform: translateY(8px) scale(1.25); opacity: 0; }
}
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .pin__ring {
    animation: none;
    opacity: 0; /* hide decorative motion */
  }
  .pin__head,
  .pin__point,
  .pin__tooltip {
    transition: none;
  }
}
The ring sits behind the pin using z-index: -1 within an isolated stacking context, so it never overlaps the tooltip. The keyframes scale the ring and fade it out to avoid stutter. The hover lift is a 2px translate that feels snappy and keeps the pointer aligned.
Live Preview
The preview below runs without external assets. Try hovering or focusing the pin with Tab.
Accessibility & Performance
Building the pin as a button means it’s keyboard focusable and screen readers can discover it in a predictable way. The tooltip is tied to the pin via aria-describedby, and role=”tooltip” helps AT interpret it as a descriptive label rather than extra content. Decorative pieces, the head, point, and ring, are marked aria-hidden=”true” so they don’t clutter the accessibility tree. The reduced motion query disables the pulse for users who prefer a calmer UI.
Accessibility
Use clear text inside the tooltip or set aria-label on the button if you want a concise label. Keep color contrast in mind. The white outline around the head raises contrast over complex map artwork. If you add many pins, consider a tabindex strategy that keeps focus order logical across the map.
Performance
The shapes are cheap to render. Triangles made with borders have zero layout cost beyond their borders. The pulse runs on opacity and transform, which are GPU-friendly properties. Avoid animating shadows or large blurs over complex map images. If you need hundreds of pins, avoid giant box-shadows and keep transitions short.
Shape Building Blocks You Can Reuse
The pin head comes straight from the standard approach to make a circle with CSS. The point is the same technique as a triangle down. The bubble uses the same pattern as the tooltip shape. Once you’re comfortable with these, you can swap parts: turn the tooltip into a speech bubble, add a chevron inside the pin, or swap the triangle for a subtle drop-shaped pointer using clip-path.
Map UI, Your Way
You built a scalable, themeable map pin with a hover/focus tooltip and a soft pulse, all in CSS. Swap colors, adjust size, and drop multiple pins by changing two custom properties. With these techniques, you can assemble your own location markers, badges, and callouts without images or external assets.
