Portfolio 98: How I Reskinned This Site
A walkthrough of dragging my own portfolio back to 1998. How the Windows 98 bevel actually works, why the whole thing is built from four colours and a box-shadow, and how the nav became a title bar, the footer a status bar, and every project card its own little window.
The problem
My portfolio looked like every other developer portfolio: dark background, mint accent, rounded corners, a variable font from Google. It was fine, and fine is forgettable. I wanted a skin with an opinion, and I wanted the whole thing to still be a real portfolio underneath rather than a novelty page.
My role
Design and implementation, solo. One stylesheet rewritten from scratch, six templates reworked.
Outcome
A full Windows 98 reskin with no images, no icon font, and no dependencies. Every raised button, sunken field and window frame on the site comes out of three CSS custom properties. The old light and dark themes became two real Windows appearance schemes, and the games were left completely untouched.
The tempting way to do a Windows 98 skin is to screenshot the real thing and slice it up. I banned that on day one. Every bevel, the Start flag, the warning triangle on the 404, the dithered toolbar buttons, all of it is CSS. That constraint is the reason the whole skin costs about 40KB of stylesheet and still recolours itself instantly when you switch schemes. A sliced-image skin would need a second set of images for the high contrast mode, and I would have had to redraw them by hand.
Everything in Windows 98 is a grey rectangle with a fake light source in the top left. The edge is two pixels thick on each side, and each of those pixels is a different grey. That is four tones total, and once you have them as variables you can build the whole operating system. I define them once and never write a raw hex value again.
:root {
--b-white: #ffffff; /* inner top and left */
--b-light: #dfdfdf; /* outer top and left */
--b-shadow: #808080; /* inner bottom, right */
--b-dark: #0a0a0a; /* outer bottom, right */
}
The neat part is that you do not need borders for this, and you should not use them. Borders eat into the box model and fight your padding. Instead I stack four inset box-shadows, each offset by one more pixel than the last. Order matters: the 1px pair is listed first so it paints on top of the 2px pair. Read it as two nested rectangles, the outer one drawn in the softer greys, the inner one in white and near-black.
--bevel-out:
inset -1px -1px var(--b-dark),
inset 1px 1px var(--b-white),
inset -2px -2px var(--b-shadow),
inset 2px 2px var(--b-light);
A text field is a raised button with the light source flipped: dark on the top left, white on the bottom right. A pressed button is the same idea. Swapping the four values gives me every 3D state on the site, and because they are custom properties I apply them by name and never think about the maths again. A button is one line. A sunken well for an image is one line.
--bevel-in:
inset -1px -1px var(--b-white),
inset 1px 1px var(--b-shadow),
inset -2px -2px var(--b-light),
inset 2px 2px var(--b-dark);
.btn { background: var(--face); box-shadow: var(--bevel-out); }
.well { background: var(--field); box-shadow: var(--bevel-in); }
.btn:active { box-shadow: var(--bevel-in); }
Flipping the bevel on :active is only half of it. In the real OS the label moves down and right by a pixel when you push the button, and that tiny shift is what sells the click. I do it by shuffling the padding rather than using transform, because transform would drag the bevel along with the text and the whole button would appear to slide.
.btn { padding: 5px 12px; }
.btn:active {
box-shadow: var(--bevel-pressed);
padding: 6px 11px 4px 13px; /* nudge the label down and right */
}
When a toolbar button in Windows 98 is toggled on, its face fills with a 50 percent checkerboard of white and grey. On a 256 colour display that was how you faked a lighter shade. You can rebuild it exactly with two 45 degree linear-gradients on a 2px tile, offset from each other by a single pixel. I reuse the same four lines for the active tag filter, the current page's taskbar button, and the scrollbar track.
background-image:
linear-gradient(45deg, var(--b-white) 25%, transparent 25%,
transparent 75%, var(--b-white) 75%),
linear-gradient(45deg, var(--b-white) 25%, transparent 25%,
transparent 75%, var(--b-white) 75%);
background-size: 2px 2px;
background-position: 0 0, 1px 1px;
I already had a theme toggle, and a normal dark mode would have been an anachronism sitting in the middle of a 1998 skin. So I looked up what Windows 98 actually shipped, and it had an appearance scheme called High Contrast Black. That is now my dark mode, which means the toggle is period correct instead of being the one modern thing left on the page. The switch is pure token swapping. No component knows the scheme exists.
[data-theme="dark"] {
--face: #000000;
--field: #000000;
--text: #ffffff;
/* the bevel loses its grey ramp and becomes pure outline */
--b-white: #ffffff;
--b-light: #808080;
--b-shadow: #808080;
--b-dark: #ffffff;
}
The site presents itself as one application window, so the sticky header splits in two. On top is a title bar with the classic navy to blue gradient, the page name in square brackets, and minimise, maximise and close buttons. Underneath is a menu bar whose items invert to the selection colour on hover, because 98 menus never had rollover tints, they just highlighted. The access key letters are underlined with a span.
.win-title {
background: linear-gradient(90deg, var(--title-a), var(--title-b));
color: var(--title-text);
}
.menu-bar a:hover {
background: var(--select); /* #000080 */
color: var(--select-text); /* #ffffff */
}
I gave the menu links display:inline-flex with a gap so the mail icon would sit away from its label. Then the menu rendered as H ome, W ork, A bout. Flex gap applies between every flex item, and a bare text node next to an element counts as one, so the underlined access key letter was being pushed away from the rest of its own word. The fix was to drop the gap entirely and put a margin on the one icon that actually needed it.
/* wrong: gap separates the <span> from the text node beside it */
.menu-bar a { display: inline-flex; gap: 5px; }
/* right: no gap, and space only the element that needs it */
.menu-bar a { display: inline-flex; }
.menu-bar .nav-cta-icon { margin-right: 5px; }
Fixed to the bottom, with the body given a matching padding-bottom so nothing hides underneath. It holds a Start button, one task button per page, and a tray with the scheme toggle and a live clock. The button for the page you are on is drawn pressed and dithered, so it reads as the focused window. The Windows flag is four divs in a 2x2 grid with a skew on the parent, which is close enough at 15 pixels wide that nobody will ever check.
.start-flag {
display: grid;
grid-template: 1fr 1fr / 1fr 1fr;
gap: 1px;
transform: skewY(-8deg);
}
.start-flag i:nth-child(1) { background: #ff3b30; }
.start-flag i:nth-child(2) { background: #4cd964; }
.start-flag i:nth-child(3) { background: #007aff; }
.start-flag i:nth-child(4) { background: #ffcc00; }
This is my favourite bit of the whole reskin and it needed no JavaScript. Each card in the work grid gets a title bar painted in the inactive window colours, that washed out grey Windows used for any window that did not have focus. Hovering the card swaps it to the active navy gradient. You are not hovering a card, you are focusing a window, and the grid reads like a desktop with a dozen things open on it.
.project-card-titlebar {
background: linear-gradient(90deg,
var(--title-a-off), var(--title-b-off)); /* greys */
color: var(--title-text-off);
}
.project-card:hover .project-card-titlebar {
background: linear-gradient(90deg,
var(--title-a), var(--title-b)); /* navy */
color: var(--title-text);
}
A skin this committed needs exactly one exception, or the whole page flattens into a single texture and nothing leads. So the hero is the anachronism. It is a modern card with a 22px radius, a layered soft shadow, gradient text and a coloured halo bleeding out from underneath, sitting on the teal desktop like something dropped in from twenty five years later. It carries its own palette and its own typeface, both namespaced under an --m- prefix, so none of it can leak into the Windows chrome. The rule I gave myself was that breaking the scheme once is a decision, and breaking it twice is just an inconsistent site.
/* the hero owns a palette the 98 tokens never see */
:root {
--m-surface: #ffffff;
--m-accent-a: #4f46e5;
--m-accent-b: #c026d3;
--m-shadow: 15 23 42; /* bare channels, for rgb() */
}
I wanted the hero to drift gently on its own and also lift under the pointer. You cannot do both on one element, because a keyframe animation and a transition fighting over transform will not compose, and the animation wins. The fix is to split the job across two nested divs: the outer one runs the idle drift forever, the inner one owns the hover lift and the shadow. Each has sole ownership of its own transform, and they stack naturally because one is inside the other.
.hero-float { /* outer: idle drift */
animation: heroFloat 9s ease-in-out infinite;
}
@keyframes heroFloat {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-9px); }
}
.hero-modern { /* inner: pointer lift */
transition: transform .4s cubic-bezier(.2,.8,.2,1), box-shadow .4s;
}
.hero-modern:hover { transform: translateY(-6px); }
My about page had a timeline with a gradient spine and glowing dots. Windows 98 has an obvious equivalent, which is the tree view in the left pane of Explorer, so the timeline became one. The trunk and the connector stubs are dotted lines made from a repeating-linear-gradient with a 1px on, 1px off pattern. Each entry gets a small square node with a plus sign in it, drawn from the ::before and ::after of a single empty div. Hovering an entry hides the vertical stroke, so the plus turns into a minus and the node looks like it just expanded.
/* a 1px dotted rule, no background image needed */
.timeline::before {
background: repeating-linear-gradient(
to bottom, var(--text-faint) 0 1px, transparent 1px 2px);
}
/* the [+] node: two bars from one element */
.timeline-dot::before { left: 1px; right: 1px; top: 3px; height: 1px; }
.timeline-dot::after { top: 1px; bottom: 1px; left: 3px; width: 1px; }
.timeline-entry:hover .timeline-dot::after { display: none; }
The old 404 was a page with a heading and a hidden jump game. Now it is a modal error box floating on the desktop, with a Back to reality button and a Details button that does what Details buttons always did, which is take you somewhere unhelpful. The yellow warning triangle is one div with a clip-path polygon and an exclamation mark in its ::after. The jump game moved into its own window titled 404.exe, and it now reads its colours out of the active scheme at runtime so it stays correct in high contrast mode.
.not-found-icon::before {
background: #ffd800;
clip-path: polygon(50% 2%, 98% 94%, 2% 94%);
}
/* the canvas game pulls its palette from the live scheme */
const css = getComputedStyle(document.documentElement);
const accent = css.getPropertyValue('--title-a').trim();
const bg = css.getPropertyValue('--field').trim();
A reskin is not only additive. Out went the Google Fonts request, replaced by a Tahoma and MS Sans Serif stack that is already on every machine, which also removed a render blocking request from every page. Out went smooth scrolling, the page fade transitions, the scroll reveal observer, the backdrop blur on the nav, and every single border-radius. Old interfaces were not smooth, they were instant, and leaving any of that easing in would have made the whole thing feel like a costume rather than a skin.
It is still a portfolio, so the gag is not allowed to cost anything. The window controls on each title bar are real buttons with aria-labels, and rather than sitting there dead they raise a period appropriate message box telling you the program is not responding. Focus rings are the 1px dotted outline Windows used, drawn inset so it lands inside the bevel. The prefers-reduced-motion block kills the marquee. The card title bars are decorative markup layered on a link that still works exactly as it did before.
a:focus-visible, button:focus-visible {
outline: 1px dotted var(--text);
outline-offset: -4px; /* inside the bevel, like the real thing */
}
Two things are still on the list. The desktop wallpaper is currently one of my CorteXR screenshots dimmed to 7 percent behind the teal, and a tiled pattern would be more honest to the period. And the playable games are deliberately untouched so far, so Candy Tycoon and the typing trainer still open in their modern skin. They are the obvious next pass, and a game inside a proper draggable window with a menu bar of its own is too good to leave alone.