GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Windows 98 CSS: 9 Retro OS Parts, No Library

Windows 98 CSS rebuilds a 1998 desktop out of one stylesheet and a little script — nine parts where the windows really drag and minimize.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows a session rather than a popularity list. A window has to exist before anything can sit on it (01), the glass it sits behind sets the tone (02), and the pointer you move across it is the third thing your eye lands on (03). Then the machine starts talking: it counts its devices at boot (04), hands you a desktop you can double-click (05), opens the menu those programs live in (06), and parks a clock in the corner (07). The last two are the screens nobody designs and everybody remembers — a setup wizard that never seems to finish (08) and the blue panel that ends the session (09). None of the nine loads an image file or a web font; a windows 98 css theme is usually a framework you install, while these are nine folders you copy. Next door sit Terminal UI Design for the command line that came before this desktop and Y2K Web Design for the chrome and gradients that came after it.

01Draggable window, minimize

Grab the title bar and the window follows the pointer; the minimize button folds the panel into the taskbar slot and the slot spits it back out, a two-second preview that carries motion on 15 of 23 frames.

Draggable window, minimize
Open in a new tab

01 · Draggable window, minimize — Measured area 28.026% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

setPointerCapturebox-shadowtranslate

Pointer capture on the title bar

// The title bar keeps the pointer even when it leaves the element — the offset is stored once at pointerdown
bar.addEventListener('pointerdown', function (e) {
  if (min || e.target.closest('.win__min')) return;
  e.preventDefault();
  bar.setPointerCapture(e.pointerId);
  var ox = px - e.clientX, oy = py - e.clientY;
  function mv(ev) {
    var b = dragBounds();
    px = clampNum(ev.clientX + ox, -b.x, b.x);
    py = clampNum(ev.clientY + oy, -b.y, b.y);
    place();
  }
  bar.addEventListener('pointermove', mv);
  bar.addEventListener('pointerup', function () { bar.removeEventListener('pointermove', mv); }, { once: true });
  bar.addEventListener('pointercancel', function () { bar.removeEventListener('pointermove', mv); }, { once: true });
});

The handler writes only translate and never touches top or left, while dragBounds clamps both axes to half the free space so the panel cannot walk out of the desktop area.

Two bevels stacked in one shadow

$bevel-l1: $stage-paper;               // outer light face
$bevel-d1: $subject-ink;               // outer dark face
$bevel-l2: rgba($stage-paper, .6);     // inner light face
$bevel-d2: rgba($subject-ink, .32);    // inner dark face
$bevel-raise: inset 1px 1px 0 0 $bevel-l1, inset -1px -1px 0 0 $bevel-d1, inset 2px 2px 0 0 $bevel-l2, inset -2px -2px 0 0 $bevel-d2;
$bevel-btn: inset 1px 1px 0 0 $bevel-l1, inset -1px -1px 0 0 rgba($subject-ink, .5);
$bevel-btn-press: inset 1px 1px 0 0 rgba($subject-ink, .5), inset -1px -1px 0 0 $bevel-l1;

Four inset shadows in one variable build the raised frame with light on the top-left and dark on the bottom-right, and the pressed variant is the same list with those two colors traded, so a button reads as pushed in without moving a pixel.

02CRT scanlines and curved vignette

One tile of horizontal stripes repeats over the whole screen while an elliptical shadow darkens the edges, and a single bright band drifting downward is the only thing that actually animates.

CRT scanlines and curved vignette
Open in a new tab

02 · CRT scanlines and curved vignette — Measured area 37.75% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

repeating-linear-gradientradial-gradientbackground-size

One tile, repeated by background-size

// Scanlines — the tile (4px) is defined by background-size and the stripe sits inside it
.crt__lines {
  position: absolute; inset: 0; z-index: 3; pointer-events: none;
  background-image: repeating-linear-gradient(to bottom,
    rgba($subject-ink, .38) 0 2px,
    transparent 2px 4px);
  background-size: 100% 4px;
}

The stripe is 2px of ink and 2px of nothing inside a 4px tile, and because the pattern is a background rather than a stack of elements, one animation is enough to reach 37.75% cumulative moved area over the loop.

The curve is a radial gradient

// Curved vignette — an ellipse that pulls the edges in, laid over the stripes
.crt__vig {
  position: absolute; inset: 0; z-index: 4; pointer-events: none;
  border-radius: inherit;
  background: radial-gradient(ellipse 130% 105% at 50% 46%,
    transparent 40%,
    rgba($subject-ink, .18) 64%,
    rgba($subject-ink, .55) 100%);
}

An ellipse 130% wide and 105% tall, centered slightly high at 50% 46%, keeps the corners dark while the middle stays clear, which is what makes a flat rectangle read as bulging glass.

03Three pixel cursors

Arrow, hourglass, and pointing hand are drawn as grids of 1px shadows and scaled by whole numbers only, so the edges stay hard at six times the size.

Three pixel cursors
Open in a new tab

03 · Three pixel cursors — Measured area 9.179% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

image-renderingconic-gradientsteps(1, end)

Pixels drawn as box-shadow runs

// Pixel art — horizontal runs (x y width). K is the black outline, W the white fill, S the sand
@function pxRuns($runs, $c) {
  $s: ();
  @each $r in $runs {
    @for $i from 0 through list.nth($r, 3) - 1 {
      $s: list.append($s, ((list.nth($r, 1) + $i) * 1px) (list.nth($r, 2) * 1px) 0 0 $c, comma);
    }
  }
  @return $s;
}
.px {
  display: block; width: 1px; height: 1px;
  image-rendering: pixelated;
}

A single 1px box carries the entire drawing as a shadow list, image-rendering: pixelated keeps the scaling nearest-neighbor, and six animations move the three cursors on 22 of 23 frames.

Cuts, not fades, between the three

@keyframes pxcArrowO {
  0%, 29% { opacity: 1; }
  30%, 88% { opacity: 0; }
  89%, 100% { opacity: 1; }
}
@keyframes pxcHourR {
  0%, 30% { rotate: 0deg; }
  62%, 100% { rotate: 180deg; }
}

Every swap runs on steps(1, end), so no cursor is ever caught half-faded, and the hourglass is the one part that interpolates with a linear 180-degree turn between 30% and 62% of the loop.

04Boot check line output

Device check lines stack downward one at a time, and each result word lands a beat after its label; then the screen clears once and starts counting again.

Boot check line output
Open in a new tab

04 · Boot check line output — Measured area 9.339% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

animation-fill-modeclip-pathmonospace

Rows uncovered by clip-path

@keyframes blRow3 {
  0%, 36% { clip-path: inset(0 100% 0 0); }
  41%, 75% { clip-path: inset(0 0 0 0); }
  76%, 100% { clip-path: inset(0 100% 0 0); }
}
@keyframes blRes3 {
  0%, 41% { opacity: 0; transform: translateX(8px); }
  42% { opacity: 1; transform: translateX(8px); }
  45%, 75% { opacity: 1; transform: translateX(0); }
  76%, 100% { opacity: 0; transform: translateX(0); }
}

Each row is wiped in from the left by clip-path while its result cuts in one beat later and settles into place from 8px to the right, which is how 11 separate animations end up moving 19 of 23 frames.

Five rows, five named keyframes

// Row timing is split into named keyframes instead of nth-child delays — the animation
// shorthand resets delay to 0, so only longhand properties are used here
.bl.is-demo .bl__row {
  animation-duration: $duration; animation-timing-function: $easing;
  animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.bl.is-demo .bl__row--1 { animation-name: blRow1; }
.bl.is-demo .bl__row--2 { animation-name: blRow2; }
.bl.is-demo .bl__row--3 { animation-name: blRow3; }
.bl.is-demo .bl__row--4 { animation-name: blRow4; }
.bl.is-demo .bl__row--5 { animation-name: blRow5; }

animation-fill-mode: backwards holds every row in its first frame until its own turn arrives, so no line flashes on screen before the loop reaches it.

05Double-click desktop icons

One click tints only the label, two quick clicks pop the icon and open a panel, and the arrow keys walk the selection between icons without a mouse.

Double-click desktop icons
Open in a new tab

05 · Double-click desktop icons — Measured area 12.865% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

detailaria-selectedtransform-origin

Counting clicks with e.detail

icons.forEach(function (b, i) {
  b.addEventListener('click', function (e) {
    dt.classList.remove('is-demo');
    select(i, false);
    // e.detail is the click count at the same spot — 2 or more is a double-click.
    // Touch always reports 1, so a 400ms gap is the fallback test
    if (e.detail >= 2 || (i === lastIdx && Date.now() - lastTap < 400)) openWin(i);
    lastTap = Date.now();
    lastIdx = i;
  });
});

The browser already counts repeat clicks in e.detail, so no timer is needed on a mouse, and the 400ms comparison exists only because touch events always report a count of one.

The pop starts from the icon's own center

.dt__glyph {
  display: block; color: $subject-cream; font-size: 30px; line-height: 1;
  transform-origin: 50% 50%;
}
.dt__ico.is-pop .dt__glyph {
  animation-name: dtPopOnce;
  animation-duration: 300ms; animation-timing-function: $easing;
  animation-iteration-count: 1;
}
@keyframes dtPopOnce {
  0% { transform: scale(1); }
  40% { transform: scale(1.18); }
  100% { transform: scale(1); }
}

The glyph swells to 1.18 from its own center and comes back in 300ms, a beat short enough that five animations still register movement on only 8 of 23 frames.

06Start menu flyout

A menu with a vertical band down its left side grows upward out of the bar, and a row with an arrow opens its submenu to the right unless the right edge has run out of room, in which case it folds to the left.

Start menu flyout
Open in a new tab

06 · Start menu flyout — Measured area 11.544% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-expandedwriting-modetransform-origin

Measuring before choosing a side

// Open the submenu, then measure — if the right edge runs past the window, fold it to the left.
// If the left side would be clipped too, keep the original direction
function showSub(sub) {
  hideSubs();
  sub.classList.add('is-on');
  var r = sub.getBoundingClientRect();
  if (r.right > window.innerWidth - 8) {
    sub.classList.add('is-flip');
    if (sub.getBoundingClientRect().left < 8) sub.classList.remove('is-flip');
  }
}

The submenu is shown first and measured second, because getBoundingClientRect on a hidden box returns zeros, and the flip itself is one class that swaps left: 100% for right: 100%.

The band is vertical text, not an image

.sm__band {
  display: grid; place-items: center; width: 28px;
  padding: $sp-1 0; border-radius: $r-sm 0 0 $r-sm;
  background: $color; color: $stage-paper;
  writing-mode: vertical-rl;
  font-weight: 700; font-size: 10px; line-height: 1.4; letter-spacing: 0.14em;
  white-space: nowrap;
}

writing-mode: vertical-rl turns the label on its side while it stays selectable text, and the menu grows from transform-origin: 50% 100% because it is hinged to the bar below it rather than to its own middle.

07Taskbar clock and tray

The clock at the right end counts real minutes, the bell in the tray raises a balloon above itself, and the window buttons reverse their bevel between the pressed and the raised state.

Taskbar clock and tray
Open in a new tab

07 · Taskbar clock and tray — Measured area 5.797% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

toLocaleTimeStringinsetborder-color

A clock that follows the page language

// The clock follows the page language through toLocaleTimeString and ticks by the minute
function tick() {
  var lang = document.documentElement.lang || 'ko';
  var text = new Date().toLocaleTimeString(lang, { hour: 'numeric', minute: '2-digit' });
  if (clockReal.textContent !== text) clockReal.textContent = text;
}
tick();
setInterval(tick, 5000);

The same call prints 9:41 PM on the English page and a 24-hour form wherever the locale asks for one, and a five-second interval is plenty because only the minute is ever shown.

Pressed and raised are the same four colors

// Raised — the light face sits on the top and left
.tb__task {
  height: 26px; padding: 0 $sp-2; box-sizing: border-box;
  background: $subject-cream; color: $subject-ink;
  border: 2px solid; border-color: $lite $edge $edge $lite;
  font-weight: 500; font-size: 11px; line-height: 1.4; white-space: nowrap;
  transition: transform $duration $ease-out, background-color $duration $ease-out;
}
// Pressed — the light and dark faces swap places
.tb__task[aria-pressed="true"] {
  border-color: $edge $lite $lite $edge;
  background: rgba(23, 20, 26, .07);
  font-weight: 700; transform: translateY(1px);
}

One border-color shorthand written in the opposite order is the whole pressed state, aria-pressed carries that state for assistive tech as well as for the selector, and a 1px nudge is all the movement seven animations need across 8 of 23 frames.

08Install progress and step text

The bar does not glide — it fills one square block at a time, swapping the line below it and the remaining-size number on the same beat, and a done mark pops when the last block lands.

Install progress and step text
Open in a new tab

08 · Install progress and step text — Measured area 2.866% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(12, end)linear-gradientbackground-size

A tile drawing plus a shrinking cover

// Block pattern — one tile (20px) defined by background-size, stops given as tile ratios.
// Pixel stops would stretch the gradient across the whole element instead of tiling it
.ip__fill {
  position: absolute; inset: 0;
  background-image: linear-gradient(90deg, $color 0 80%, transparent 80% 100%);
  background-size: $tile 100%;
  background-repeat: repeat-x;
}
// Track-colored cover — anchored right, shrinks by scaleX to reveal blocks from the left
.ip__cover {
  position: absolute; inset: 0;
  background: $stage-paper;
  transform-origin: right center;
  transform: scaleX(0);
}

The blocks are painted once as a repeating tile and never animated at all; what moves is a cover of track color shrinking toward the right, and that is why twelve visible steps cost only 2.866% cumulative moved area, the quietest of the nine.

Twelve numbers sharing one keyframe

@keyframes ipNm {
  0%, 6.25% { opacity: 1; }
  6.26%, 100% { opacity: 0; }
}
.ip.is-demo .ip__nm--1 { animation-delay: 0ms; }
.ip.is-demo .ip__nm--2 { animation-delay: -1875ms; }
.ip.is-demo .ip__nm--3 { animation-delay: -1750ms; }
.ip.is-demo .ip__nm--4 { animation-delay: -1625ms; }
.ip.is-demo .ip__nm--5 { animation-delay: -1500ms; }
.ip.is-demo .ip__nm--6 { animation-delay: -1375ms; }
.ip.is-demo .ip__nm--7 { animation-delay: -1250ms; }
.ip.is-demo .ip__nm--8 { animation-delay: -1125ms; }
.ip.is-demo .ip__nm--9 { animation-delay: -1000ms; }
.ip.is-demo .ip__nm--10 { animation-delay: -875ms; }
.ip.is-demo .ip__nm--11 { animation-delay: -750ms; }
.ip.is-demo .ip__nm--12 { animation-delay: -625ms; }

All twelve numbers share the same 6.25% flash, and a negative animation-delay shifts each one into its own slot, which is how 19 animations produce motion on 20 of 23 frames while the layout never reflows.

09Blue error screen and restart

The error text arrives on a blue field in a single frame, three dots light one after another, and restart wipes the panel away from top to bottom to reveal the window underneath.

Blue error screen and restart
Open in a new tab

09 · Blue error screen and restart — Measured area 36.953% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(1, end)clip-pathanimation-delay

A one-step cut needs a keyframe before it

// Entry cut — the middle keyframe at 7% is what makes the change land in one frame at 8%
@keyframes bsCut {
  0%, 7% { opacity: 0; }
  8%, 100% { opacity: 1; }
}
// Exit wipe — a paired animation that interpolates with an easing instead of steps
@keyframes bsWipe {
  0%, 58% { clip-path: inset(0 0 0 0); }
  70%, 100% { clip-path: inset(100% 0 0 0); }
}

A steps(1, end) animation changes value at the end of its step, so a plain two-keyframe cut would leave the blue field invisible for the entire loop, and splitting the cut and the wipe into two animations lets one be discrete while the other eases.

Restart as a clip-path wipe

// System error — the blue screen arrives whole, so there is no transition, only a class
crash.addEventListener('click', function () {
  screen.classList.remove('is-wiped');
  screen.classList.add('is-crash');
  restart.focus({ preventScroll: true });
});
// Restart — the screen is wiped from top to bottom and the original window is revealed
restart.addEventListener('click', function () { screen.classList.add('is-wiped'); });
screen.addEventListener('transitionend', function (e) {
  if (e.propertyName !== 'clip-path') return;
  screen.classList.remove('is-crash', 'is-wiped');
});

Focus jumps to the restart button the moment the panel appears, so the keyboard never ends up stranded behind it, cleanup waits for transitionend on clip-path alone, and at its widest the blue field covers 36.953% of the stage.

How far does a windows 98 taskbar css go without images?

Further than the frameworks make it look. The raised and pressed edges are border-color written in two orders, the icons are inline SVG, the stripes and the progress blocks are gradients, and the cursors are shadow grids — there is no sprite sheet anywhere in the nine. The ceiling shows up in two places: a true bitmap typeface, which needs a real font file to look right at 11px, and window management past a single panel, where z-order, focus return, and resize handles start asking for a state model instead of a class. A css crt monitor effect has the same ceiling, since three stacked layers give you the tube while genuine barrel distortion of the content wants an SVG filter or WebGL. The properties doing the work are documented on MDN: image-rendering, repeating-linear-gradient, radial-gradient, and Pointer events. For a pointer that leaves the retro frame behind, 9 Custom Cursor CSS Effects picks up the modern side.

Where it breaks — the trap

The preview in the grid and the thing under your hands are two different code paths. The loop is CSS keyframes attached to .is-demo, and the real behavior is JavaScript that only starts once that class is removed, so a part can look perfect in a poster while its interactive half is dead.

Cuts that never cut

A cut driven by a single step changes value at the end of that step, which means a bare pair of keyframes under steps(1, end) shows the old value for the whole two seconds. 09 carries an explicit keyframe at 7% for exactly that reason, and 03 gives each cursor two named animations so the opacity cut and the movement can run on different timing functions instead of fighting over one. The password for the nine zip folders is sqx22eqz, and it is worth typing because every folder holds the plain build and the React build side by side.

Three icons that did not fit

05 stacks its icons in a column, and at 320px that column alone takes 155px of height, which leaves nothing for the window it is supposed to open on a 320×200 stage. The narrow layout turns the column into a row and lets the panel cover the icons instead of sitting beside them, and 06 does the same kind of surrender by hiding its background console entirely below 340px.

Static states that must never be empty

Removing the loop cannot leave a blank rectangle, so each part declares what it looks like when nothing moves. 04 keeps all five check rows filled with the logo hidden, 05 keeps the first icon selected with its window open, 08 stays at the finished state with the completion mark visible, and 09 hides the blue field so the system window is what remains on screen.

Three variants and accessibility

Halve $dur-loop, swap $ease-spring for $ease-pop, or trade the subject blue for the stage orange, and the whole nine changes feel together.

name changed value feel
duration $dur-loop 2s → 1s boot lines and install blocks run twice as fast
easing $ease-spring → $ease-pop windows and menus bounce once more as they open
color $subject-blue → $stage-orange selection color and title bars move from blue to orange

Every control is a real <button>, so Enter and Space work with no key handler of our own. The title bar in 01 takes focus and moves 4px per arrow press or 16px with Shift held, 05 walks its icon grid with the arrow keys and reports the choice through aria-selected, 06 keeps aria-expanded on the start button, and 07 flips aria-pressed rather than a private class. Contrast was measured on the static path across 38 text samples with none below AA, the lowest being the small BIOS subtitle at 4.62:1 on #0a4fe8 and the cream title-bar text on that same blue at 5.98:1.

What reduced motion leaves on screen

Under prefers-reduced-motion: reduce every preview loop stops and the drag transition in 01 goes with it, leaving each part on the one frame that reads best. The band in 02 parks out of sight instead of sweeping, the cursors in 03 hold the arrow, and nothing on the page animates by itself after that.

FAQ

What makes windows 98 border css look raised instead of flat

Two faces instead of one. A light color on the top and left with a dark color on the bottom and right reads as a lamp above and behind the viewer, and the very same four values written in the opposite order read as pressed in. 01 needs four inset shadows because its frame has two rings, while 07, 08, and 09 get there with a single border-color.

Is there a React version of the nine parts

Yes. The zip carries a react/ folder beside the plain build, with a .tsx component and a .module.scss stylesheet for each of the nine, and the compile check reports no type errors. The markup and the handlers are the ones printed above, moved into refs and state.

Do the pixel cursors stay sharp when the page is zoomed

They stay sharp while the scale is a whole number: image-rendering: pixelated asks for nearest-neighbor sampling, and the preview scales by 6 with the narrow layout dropping to 4 rather than to something fractional. Browser zoom multiplies that by its own factor, so at 110% the edges soften a little, which is the one place a real cursor file still wins.

Enter the archive password

The password is inside this article. You will find it as you read.