Project: Code a “Blinking Eye” with CSS

Build a polished, blinking eye entirely in CSS. You will create an elliptical eye with an iris, pupil, highlight, and animated eyelids, no images or SVG required. By the end, you will understand how to layer shapes, control perspective with border-radius, and animate eyelids with pseudo-elements using only CSS.

Why a CSS “Blinking Eye” Matters

A CSS-driven icon or mascot scales cleanly, adapts to themes, and loads instantly. A blinking eye is a compact demo of several valuable techniques: shape building, layering with pseudo-elements, and timeline animation with keyframes. You can use it as a loader, a playful brand touch, or a teaching component in a UI pattern library. Unlike an animated GIF, this eye remains crisp at any size and responds to CSS variables, color schemes, and motion preferences.

Prerequisites

You do not need a framework or JavaScript for this build. A text editor and a browser are enough. If you know how to position elements and work with pseudo-elements, you will feel right at home.

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

Step 1: The HTML Structure

The eye is one self-contained component. The outer wrapper centers the stage. The eye container holds the elliptical sclera and two pseudo-elements that form the eyelids. Inside the sclera, the iris, pupil, and a small glare complete the look.

<!-- HTML -->
<div class="stage">
  <div class="eye" role="img" aria-label="Blinking eye">
    <div class="eye__sclera">
      <div class="eye__iris">
        <div class="eye__pupil"></div>
        <span class="eye__glare"></span>
      </div>
    </div>
  </div>
</div>

Step 2: The Basic CSS & Styling

Start with a centered canvas, theme variables, and a baseline size. The eye will scale by adjusting the –size custom property. The body gets a dark background so the white sclera and cyan iris pop.

/* CSS */
:root {
  --size: 260px;
  --bg: #0f172a;
  --sclera: #fafafa;
  --iris: #22d3ee;
  --iris-ring: #06b6d4;
  --pupil: #0b1020;
  --lid: #0f172a; /* match background to hide lids seamlessly */
  --line: #94a3b8;
  --shadow: rgba(0,0,0,0.3);
  --blink-cycle: 5s;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html, body {
  height: 100%;
}

body {
  margin: 0;
  background: radial-gradient(1200px 800px at 50% 0%, #0b1324 0%, var(--bg) 60%);
  color: #e2e8f0;
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
}

.stage {
  min-height: 100%;
  display: grid;
  place-items: center;
  padding: 2rem;
}

Advanced Tip: Expose all colors and sizes as CSS variables. You gain instant theming, quick prototyping, and trivial dark or light mode swaps without touching the component markup.

Step 3: Building the Eye Core

The eye core covers the sclera (white shape), iris, pupil, and glare. The sclera uses an elliptical border-radius. The iris and pupil are circles, centered with absolute positioning. A subtle gradient and inner shadows add depth without images.

/* CSS */
.eye {
  position: relative;
  width: var(--size);
  height: calc(var(--size) * 0.62);
  display: grid;
  place-items: center;
  filter: drop-shadow(0 8px 24px var(--shadow));
}

.eye__sclera {
  position: relative;
  width: 100%;
  height: 100%;
  /* Elliptical eye using slash syntax: horizontal / vertical radii */
  border-radius: 50% / 65%;
  background:
    radial-gradient(90% 70% at 50% 50%, #ffffff 0% 60%, var(--sclera) 61% 100%);
  border: 3px solid var(--line);
  box-shadow:
    inset 0 -6px 10px rgba(0,0,0,0.06),
    inset 0 6px 10px rgba(255,255,255,0.2);
  overflow: hidden; /* hide iris while lids move */
}

.eye__iris {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--size) * 0.38);
  height: calc(var(--size) * 0.38);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background:
    radial-gradient(120% 120% at 30% 30%, #7dd3fc 0 20%, transparent 21%),
    radial-gradient(75% 75% at 50% 50%, var(--iris) 0 55%, var(--iris-ring) 56% 70%, transparent 71%);
  box-shadow:
    inset 0 0 0 6px rgba(255,255,255,0.06),
    inset 0 16px 22px rgba(0,0,0,0.2);
}

.eye__pupil {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--size) * 0.16);
  height: calc(var(--size) * 0.16);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: radial-gradient(70% 70% at 45% 45%, #1f2937 0 45%, var(--pupil) 46% 100%);
  box-shadow:
    0 0 0 3px rgba(0,0,0,0.2),
    inset 0 -6px 10px rgba(255,255,255,0.08);
}

.eye__glare {
  position: absolute;
  left: 46%;
  top: 44%;
  width: calc(var(--size) * 0.07);
  height: calc(var(--size) * 0.07);
  transform: translate(-50%, -50%) rotate(-15deg);
  display: block;
  border-radius: 50%;
  background: radial-gradient(100% 100% at 50% 50%, rgba(255,255,255,0.95) 0 60%, rgba(255,255,255,0) 61% 100%);
  filter: blur(0.2px);
  pointer-events: none;
}

How This Works (Code Breakdown)

The sclera uses the border-radius slash syntax to produce an ellipse: 50% horizontal and 65% vertical radii. If you want to study that pattern in isolation, this mirrors the technique in the guide on how to make an ellipse with CSS. A soft radial gradient and inset shadows keep the eye from looking flat.

The iris sits dead center using left: 50%, top: 50%, and a translate to counter the offset. Two layered radial gradients create a bright core and a ring around it. The pupil is a simple centered circle with a darker gradient. You can cross-check the mechanics of circular shapes in the tutorial on how to make a circle with CSS. The glare is a tiny radial gradient used as a highlight to sell the glassy surface.

The sclera has overflow: hidden. This gives us a clean mask for moving lids. The drop shadow on the .eye parent separates the component from the background and adds a bit of depth.

Step 4: Building the Eyelids

The eyelids are pseudo-elements on the .eye container. They match the background color, which makes them appear like solid lids sliding over the sclera. We curve the inner edges to match the eye shape and animate them later.

/* CSS */
.eye::before,
.eye::after {
  content: "";
  position: absolute;
  left: 0;
  width: 100%;
  height: 55%;
  background: var(--lid);
  z-index: 2; /* above sclera and iris */
  /* Curve the inner edges so lids follow the eye arc */
  border-top-left-radius: 50% 80%;
  border-top-right-radius: 50% 80%;
  border-bottom-left-radius: 50% 80%;
  border-bottom-right-radius: 50% 80%;
  /* Slight border line to define lids against sclera edge */
  box-shadow: 0 -1px 0 rgba(255,255,255,0.06), 0 1px 0 rgba(0,0,0,0.35);
  will-change: transform;
}

.eye::before {
  top: 0;
  transform: translateY(-120%);
  border-bottom-left-radius: 50% 65%;
  border-bottom-right-radius: 50% 65%;
}

.eye::after {
  bottom: 0;
  transform: translateY(120%);
  border-top-left-radius: 50% 65%;
  border-top-right-radius: 50% 65%;
}

How This Works (Code Breakdown)

Using ::before and ::after avoids extra nodes in the HTML and keeps the eye self-contained. Both lids start off-screen using translateY. The curved border radii on the inner edges keep the lids aligned with the ellipse so the blink looks natural. Matching the background color hides the lid edges where they exit the component, which helps the illusion.

If you want a stylized, feline look, you can swap these lids for large border-based triangles. That approach uses the classic CSS triangle trick, the same one covered in the guide on how to make a triangle up with CSS. Triangular lids produce sharp, graphic blinks that work well for logo accents.

Advanced Techniques: Adding Animations & Hover Effects

The blink runs on two keyframes, one for the top lid and one for the bottom lid. The cycle stays open for most of the timeline, then snaps shut, then opens again. We add a hover effect that slightly enlarges the pupil and iris ring to simulate a light change. A reduced-motion query disables the blinking for motion-sensitive users.

/* CSS */
@keyframes lid-top {
  0%, 88% {
    transform: translateY(-120%);
  }
  92% {
    transform: translateY(0%); /* closed */
  }
  96%, 100% {
    transform: translateY(-120%);
  }
}

@keyframes lid-bottom {
  0%, 88% {
    transform: translateY(120%);
  }
  92% {
    transform: translateY(0%); /* closed */
  }
  96%, 100% {
    transform: translateY(120%);
  }
}

/* Play the blink */
.eye::before {
  animation: lid-top var(--blink-cycle) cubic-bezier(.2,.6,.3,1) infinite;
}

.eye::after {
  animation: lid-bottom var(--blink-cycle) cubic-bezier(.2,.6,.3,1) infinite;
}

/* Slight desynchronization for a more organic feel (optional) */
.eye::after {
  animation-delay: 8ms;
}

/* Hover: the pupil expands a touch, iris ring tightens */
.eye:hover .eye__pupil {
  transform: translate(-50%, -50%) scale(1.08);
}

.eye:hover .eye__iris {
  filter: saturate(1.1) brightness(1.05);
}

/* Focus-visible: match hover feedback for keyboard users */
.eye:focus-visible .eye__pupil {
  transform: translate(-50%, -50%) scale(1.08);
  outline: 3px solid #38bdf8;
  outline-offset: 6px;
}

/* Motion preferences */
@media (prefers-reduced-motion: reduce) {
  .eye::before,
  .eye::after {
    animation: none;
    transform: translateY(-120%); /* lids parked off-canvas */
  }
}

The blink timing holds the open state across most of the cycle and compresses the closing and opening into a short window. This mirrors how real eyes blink. The small delay between lids reduces the mechanical look. The hover and focus-visible feedback keep the component lively without being distracting.

Accessibility & Performance

Accessibility

Decide if the eye conveys meaning. If it is decorative, add aria-hidden=”true” on the .eye element or on a wrapper so screen readers skip it. If it communicates status, keep role=”img” with a clear aria-label like “Blinking eye loader.” The hover and focus-visible behaviors support mouse and keyboard users equally. The prefers-reduced-motion media query pauses the blink, which avoids unwanted movement for users who disable animation at the OS level.

Performance

The animation only changes transform on two pseudo-elements. Modern browsers handle this on the compositor thread, which keeps frames smooth. The component avoids layout thrash by not animating properties like width, height, or box-shadow. The gradients are static and render once. The eye scales by adjusting a single –size variable, which keeps CSS tidy and avoids multiple variants.

Step 5: Theming, Sizing, and Variations

To switch sizes, change –size. Everything inside scales proportionally. For a calmer eye, lower contrast in –iris and –iris-ring. For a darker UI, bring the sclera down a notch. For a stylized emoji-like eye, widen the ellipse (increase the height ratio) and drop the border. You can also swap the sclera to a soft tan and the background to a light page, then invert the lid color.

If you want a sharper, geometric eyelid silhouette, consider adding a small notch using a triangle pseudo-element layered above the lid. That notch uses the same border technique described for triangles, such as the entry for how to make a triangle down with CSS. It slots right at the lid edge and gives a stylized illustration look.

Troubleshooting

If the lids do not fully cover the iris during a blink, increase their height slightly above 50% or nudge the translateY values to exceed 100%. If the eyelid curvature feels off, adjust the vertical radii in the border-radius pairs on the lid pseudo-elements until the inner edge matches your sclera’s arc. If the blink looks too robotic, widen the open window by shifting the 88% and 96% keyframe stops further apart and vary the easing curve.

Complete CSS Listing

If you prefer to copy one snippet, here is the full stylesheet gathered in one place. It matches the steps above.

/* CSS */
:root {
  --size: 260px;
  --bg: #0f172a;
  --sclera: #fafafa;
  --iris: #22d3ee;
  --iris-ring: #06b6d4;
  --pupil: #0b1020;
  --lid: #0f172a;
  --line: #94a3b8;
  --shadow: rgba(0,0,0,0.3);
  --blink-cycle: 5s;
}

*,
*::before,
*::after { box-sizing: border-box; }

html, body { height: 100%; }

body {
  margin: 0;
  background: radial-gradient(1200px 800px at 50% 0%, #0b1324 0%, var(--bg) 60%);
  color: #e2e8f0;
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
}

.stage {
  min-height: 100%;
  display: grid;
  place-items: center;
  padding: 2rem;
}

/* Eye core */
.eye {
  position: relative;
  width: var(--size);
  height: calc(var(--size) * 0.62);
  display: grid;
  place-items: center;
  filter: drop-shadow(0 8px 24px var(--shadow));
}

.eye__sclera {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 50% / 65%;
  background: radial-gradient(90% 70% at 50% 50%, #ffffff 0% 60%, var(--sclera) 61% 100%);
  border: 3px solid var(--line);
  box-shadow: inset 0 -6px 10px rgba(0,0,0,0.06), inset 0 6px 10px rgba(255,255,255,0.2);
  overflow: hidden;
}

.eye__iris {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--size) * 0.38);
  height: calc(var(--size) * 0.38);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background:
    radial-gradient(120% 120% at 30% 30%, #7dd3fc 0 20%, transparent 21%),
    radial-gradient(75% 75% at 50% 50%, var(--iris) 0 55%, var(--iris-ring) 56% 70%, transparent 71%);
  box-shadow: inset 0 0 0 6px rgba(255,255,255,0.06), inset 0 16px 22px rgba(0,0,0,0.2);
  transition: filter .2s ease, transform .2s ease;
}

.eye__pupil {
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--size) * 0.16);
  height: calc(var(--size) * 0.16);
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: radial-gradient(70% 70% at 45% 45%, #1f2937 0 45%, var(--pupil) 46% 100%);
  box-shadow: 0 0 0 3px rgba(0,0,0,0.2), inset 0 -6px 10px rgba(255,255,255,0.08);
  transition: transform .2s ease;
}

.eye__glare {
  position: absolute;
  left: 46%;
  top: 44%;
  width: calc(var(--size) * 0.07);
  height: calc(var(--size) * 0.07);
  transform: translate(-50%, -50%) rotate(-15deg);
  display: block;
  border-radius: 50%;
  background: radial-gradient(100% 100% at 50% 50%, rgba(255,255,255,0.95) 0 60%, rgba(255,255,255,0) 61% 100%);
  filter: blur(0.2px);
  pointer-events: none;
}

/* Eyelids */
.eye::before,
.eye::after {
  content: "";
  position: absolute;
  left: 0;
  width: 100%;
  height: 55%;
  background: var(--lid);
  z-index: 2;
  border-top-left-radius: 50% 80%;
  border-top-right-radius: 50% 80%;
  border-bottom-left-radius: 50% 80%;
  border-bottom-right-radius: 50% 80%;
  box-shadow: 0 -1px 0 rgba(255,255,255,0.06), 0 1px 0 rgba(0,0,0,0.35);
  will-change: transform;
}

.eye::before {
  top: 0;
  transform: translateY(-120%);
  border-bottom-left-radius: 50% 65%;
  border-bottom-right-radius: 50% 65%;
}

.eye::after {
  bottom: 0;
  transform: translateY(120%);
  border-top-left-radius: 50% 65%;
  border-top-right-radius: 50% 65%;
}

/* Animation */
@keyframes lid-top {
  0%, 88% { transform: translateY(-120%); }
  92%      { transform: translateY(0%); }
  96%,100% { transform: translateY(-120%); }
}

@keyframes lid-bottom {
  0%, 88% { transform: translateY(120%); }
  92%      { transform: translateY(0%); }
  96%,100% { transform: translateY(120%); }
}

.eye::before { animation: lid-top var(--blink-cycle) cubic-bezier(.2,.6,.3,1) infinite; }
.eye::after  { animation: lid-bottom var(--blink-cycle) cubic-bezier(.2,.6,.3,1) infinite 8ms; }

/* Interactions */
.eye:hover .eye__pupil,
.eye:focus-visible .eye__pupil {
  transform: translate(-50%, -50%) scale(1.08);
}

.eye:hover .eye__iris,
.eye:focus-visible .eye__iris {
  filter: saturate(1.1) brightness(1.05);
}

.eye:focus-visible { outline: 3px solid #38bdf8; outline-offset: 6px; }

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
  .eye::before,
  .eye::after {
    animation: none !important;
    transform: translateY(-120%);
  }
}

Production Notes

Keep the sclera border subtle. A heavy stroke can clash with the dark lids. If your background is light, set –lid to match that light tone and darken the sclera border color to retain shape definition. For very small sizes, drop the glare and inner shadows to reduce visual noise. For very large sizes, consider adding a faint iris texture by mixing an extra repeating-radial-gradient layer.

If you plan to animate gaze direction, place the iris and pupil inside a wrapper and animate that wrapper’s transform. That avoids scaling the pupil when you shift the iris position. Since the sclera masks overflow, the motion stays crisp without extra clipping layers.

Ship a Playful Detail

You built a complete, blinking eye with layered gradients, elliptical geometry, and two lid animations powered by pseudo-elements. The component scales, themes, and plays well with motion settings. You now have a small, expressive building block you can tweak for mascot icons, loaders, or onboarding screens.

Leave a Comment