How to Build a “Bouncing Ball” Loading Animation

Introduction

Let’s build a pure CSS map pin (location marker) you can drop onto any map, image, or dashboard. The component will be themeable with CSS variables, support labels, and include optional hover pulse for emphasis. You’ll learn how to combine a circular “head” with a triangular pointer using only CSS, then add small touches that make it production-ready.

Why a Pure CSS Map Pin Matters

Inline SVGs and images work, but they add files and feel rigid once you need states, themes, and subtle motion. A CSS-only pin scales cleanly with font-size or a single variable, adopts dark/light themes instantly, and can be tweaked with a few properties. You also avoid asset management headaches for color variants or sizes. The triangle pointer and circular head are perfect candidates for CSS primitives, and the result renders crisply across devices.

Prerequisites

You don’t need a UI framework or an icon set for this one, just HTML and CSS. If you’ve used pseudo-elements and CSS variables before, you’ll feel at home.

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

Step 1 – The HTML Structure

The markup is a simple “map” container with a few pins inside. Each pin is a button for keyboard support, positioned by percentage coordinates. One pin uses a label to show how to annotate a point of interest, and one uses a diamond modifier to demonstrate a different head shape.

<div class="map" role="img" aria-label="Example map with location pins">
  <button class="pin" style="--x: 22%; --y: 48%" aria-label="Downtown store"></button>

  <button class="pin pin--diamond" style="--x: 65%; --y: 32%" aria-label="Warehouse"></button>

  <button class="pin pin--label" style="--x: 46%; --y: 72%" aria-label="Pickup point">
    <span class="pin__text">Pick‑up</span>
  </button>
</div>

The map element is a positioned container that simulates a map area. Each .pin is absolutely positioned via CSS variables –x and –y, so you can “drop” a pin anywhere without touching CSS files. Labels are optional and sit above the pin.

Step 2 – The Basic CSS & Styling

Set the page defaults, create a responsive map area, and define theme variables for color and size. This is the foundation that keeps the component flexible and readable. The background grid only serves as a stand-in for a real map.

/* CSS */

:root {
  --pin-color: #e63946;
  --pin-rgb: 230, 57, 70; /* for translucent effects */
  --pin-size: 28px;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
  color: #0b0b0b;
}

.map {
  position: relative;
  width: min(92vw, 720px);
  height: 420px;
  margin: 32px auto;
  border-radius: 12px;
  overflow: hidden;
  background:
    linear-gradient(180deg, rgba(0,0,0,0.03), rgba(0,0,0,0.03)),
    repeating-linear-gradient(0deg, rgba(0,0,0,0.05) 0 1px, transparent 1px 40px),
    repeating-linear-gradient(90deg, rgba(0,0,0,0.05) 0 1px, transparent 1px 40px),
    #f5f7fb;
}

/* Centered live preview helper (transparent background) */
.live-preview {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 28px;
  margin: 16px 0 8px;
  background: transparent;
}

Pro Tip: Pull colors and sizes into CSS variables. A single –pin-color and –pin-size make theming and scaling predictable across an entire map or even across different products.

Step 3 – Building the Pin

Time to draw the pin from two primitives: a circular head and a downward pointer. The head is a rounded pseudo-element with a subtle highlight; the pointer is a border-based triangle.

/* CSS */

.pin {
  --size: var(--pin-size);
  --color: var(--pin-color);
  --pointer: calc(var(--size) * 0.75);
  position: absolute;
  top: var(--y);
  left: var(--x);

  /* anchor the pin's bottom center to the coordinate */
  transform: translate(-50%, -100%);
  width: var(--size);
  height: calc(var(--size) + var(--pointer));

  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;

  /* visual polish */
  filter: drop-shadow(0 2px 8px rgba(0,0,0,0.18));
}

/* head */
.pin::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: var(--size);
  height: var(--size);
  border-radius: 50%;
  background:
    radial-gradient(circle at 35% 30%, rgba(255,255,255,0.55) 0 35%, transparent 36%),
    var(--color);
  box-shadow:
    inset 0 -2px 0 rgba(0,0,0,0.12),
    inset 0 2px 0 rgba(255,255,255,0.5);
}

/* pointer: down triangle */
.pin::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: calc(var(--size) / 2.6) solid transparent;
  border-right: calc(var(--size) / 2.6) solid transparent;
  border-top: var(--pointer) solid var(--color);
}

/* focus accessibility */
.pin:focus-visible {
  outline: none;
}
.pin:focus-visible::before {
  box-shadow:
    0 0 0 3px rgba(var(--pin-rgb), 0.25),
    inset 0 -2px 0 rgba(0,0,0,0.12),
    inset 0 2px 0 rgba(255,255,255,0.5);
}

/* optional label */
.pin--label .pin__text {
  position: absolute;
  left: 50%;
  transform: translate(-50%, -100%) translateY(-10px);
  background: #fff;
  color: #1a1a1a;
  font-weight: 600;
  font-size: 12px;
  line-height: 1.2;
  padding: 6px 8px;
  border-radius: 6px;
  box-shadow: 0 6px 16px rgba(0,0,0,0.12);
  white-space: nowrap;
}
.pin--label .pin__text::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: -5px;
  transform: translateX(-50%);
  width: 0;
  height: 0;
  border-left: 6px solid transparent;
  border-right: 6px solid transparent;
  border-top: 6px solid #fff;
}

How This Works (Code Breakdown)

The .pin element anchors to percentage coordinates with –x and –y. The transform translate(-50%, -100%) shifts its bottom center to meet the coordinate, which feels natural for map markers since you place them by the tip.

The circular head lives in ::before. A border-radius of 50% and a solid background color create the core shape. The radial-gradient adds a soft top-left highlight that reads as a light source, and the inset shadows add depth so the head doesn’t look flat. If you want to study circular primitives on their own, see the reference on how to make a circle with CSS.

The pointer is a pure border triangle in ::after. Two transparent side borders shape the slanted sides, and border-top paints the down-facing fill. This is the classic CSS triangle trick; you can revisit the fundamentals in the guide on making a down-pointing triangle with CSS. The triangle’s width scales with the head size so both stay in proportion.

Labels use absolute positioning and a small white bubble with a mini triangle tail. The tail repeats the triangle trick with a tiny border-top. The focus-visible style gives keyboard users a clear ring without changing the shape’s geometry.

Step 4 – Variant: Diamond-Head Pin

Sometimes a second marker style helps differentiate categories. This variant swaps the circular head for a diamond while keeping the same pointer and proportions.

/* CSS */

.pin--diamond::before {
  border-radius: 0;
  background:
    radial-gradient(circle at 35% 30%, rgba(255,255,255,0.5) 0 28%, transparent 29%),
    var(--color);
  transform: translateZ(0) rotate(45deg);
  /* keep it optically centered */
  box-shadow:
    inset 0 -2px 0 rgba(0,0,0,0.12),
    inset 0 2px 0 rgba(255,255,255,0.5);
}

How This Works (Code Breakdown)

The diamond is a rotated square. Dropping border-radius and applying rotate(45deg) turns the square into a diamond while preserving the existing dimensions and shadow logic. If you want a dedicated formula for rendering this shape from scratch, the pattern mirrors the page on how to make a diamond with CSS.

Because the pointer and coordinate anchoring remain identical, you can mix circles and diamonds on the same map without rethinking layout. The transform doesn’t affect the visual tip alignment; the bottom center still lands on the target coordinate.

Advanced Techniques: Hover Pulse & Subtle Transitions

A gentle pulse draws attention without hijacking the layout. This approach animates a translucent ring from the head using box-shadow. It also respects prefers-reduced-motion to avoid motion for users who request it.

/* CSS */

.pin { transition: transform 0.15s ease, filter 0.2s ease; }
.pin:hover { filter: drop-shadow(0 4px 14px rgba(0,0,0,0.22)); }

/* pulse on head */
.pin:hover::before,
.pin:focus-visible::before {
  animation: pin-pulse 1.2s ease-out 0s 1;
}

@keyframes pin-pulse {
  0% {
    box-shadow:
      0 0 0 0 rgba(var(--pin-rgb), 0.34),
      inset 0 -2px 0 rgba(0,0,0,0.12),
      inset 0 2px 0 rgba(255,255,255,0.5);
  }
  70% {
    box-shadow:
      0 0 0 18px rgba(var(--pin-rgb), 0),
      inset 0 -2px 0 rgba(0,0,0,0.12),
      inset 0 2px 0 rgba(255,255,255,0.5);
  }
  100% {
    box-shadow:
      inset 0 -2px 0 rgba(0,0,0,0.12),
      inset 0 2px 0 rgba(255,255,255,0.5);
  }
}

/* motion safety */
@media (prefers-reduced-motion: reduce) {
  .pin, .pin::before { animation: none !important; transition: none !important; }
}

The hover shadow and small transform create a tactile press effect. The pulse expands as a soft ring that fades to transparent. The animation runs once per hover/focus to avoid constant motion.

Accessibility & Performance

Designing UI chrome is only half the job; it needs to read well in assistive tech and render smoothly on a wide range of devices.

Accessibility

Use interactive elements when the marker triggers actions. A button provides keyboard focus, Enter/Space activation, and focus-visible states. Set aria-label to describe the location or action (e.g., “Downtown store” or “Open location details”). If the pin is decorative, drop tabindex and add aria-hidden=”true”. For labels that duplicate visible text inside the bubble, keep aria-label short and avoid redundancy. Honor reduced motion: the prefers-reduced-motion media query removes pulses for motion-sensitive users.

Performance

Border triangles, border-radius, and transforms are cheap to render. The drop-shadow filter is reasonable for single elements; avoid stacking multiple large shadows on dozens of pins if the map is dense. Animating box-shadow costs more than transform or opacity, so limit pulse frequency or scope it to hover/focus. There’s no layout thrash here since the pin size stays constant; only paint/composite work increases during interactions.

Small CSS Pieces That Travel Well

You now have a fully themeable map pin built from a circle and a triangle, plus a diamond variant and a label bubble. The same approach can adapt to category colors, selection states, and clustered markers. Keep the variables, drop these pins onto any map or image, and compose your own set of location markers with confidence.

Leave a Comment