Modern CSS: Grid, Variables and Dark Mode
The theme system I built while writing this site in plain CSS, the grid overflows I fixed and the contrast corrections I made. No libraries, just real code.
I wrote the CSS for the site you are reading right now from scratch, without a library: one file, around 2,600 lines. Having worked for years with layout systems like Auto Layout and Compose on the mobile side, I started out thinking “CSS layout is easy,” and then lost plenty of time to grid overflows and to gray text nobody could read in dark mode. What follows are the problems I actually ran into along the way and the fixes I used.
A theme is not a list of colors, it is a list of roles
On my first attempt I named my variables after colors: --gri-acik, --mavi-koyu. The day I added dark mode those names stopped meaning anything, because “light gray” had become a dark color in dark mode. Once I named them after their role instead of their color, everything fell into place: background, surface, border, text, dim text.
I keep the values that never change inside :root; the ones that change with the theme I collect in two blocks tied to the data-theme attribute on .
:root {
--brand-1: #5B7CFA;
--radius: 16px;
--maxw: 1200px;
--gutter: clamp(20px, 5vw, 48px);
}
[data-theme="light"] {
--bg: #FCFCFE;
--surface: #FFFFFF;
--text: #10132A;
--text-dim: #6B7189;
color-scheme: light;
}
[data-theme="dark"] {
--bg: #08080D;
--surface: #12121C;
--text: #EDEDF2;
--text-dim: #8A8AA0;
color-scheme: dark;
}
Components no longer see a raw color value anywhere; they just say background: var(--surface). Adding a new theme comes down to opening a new selector and giving the same names different values.
The color-scheme line matters more than it looks. It brings the parts the browser draws itself — the scrollbar, form controls, the autofill background — in line with the theme. Without it you end up with a bright white scrollbar down the edge of a dark page.
Stopping the flash on theme load
I keep the theme choice in localStorage, but the real question is when you apply it. My main script loads at the end of the page with defer; when I put the theme code there, a visitor who had chosen dark mode saw a white page first and the screen went dark a moment later. Classic FOUC.
The fix is a small blocking script (no defer) inside . It runs before the first paint, so there is no white flash:
<script>
(function () {
try {
var kayitli = localStorage.getItem('dt-tema');
document.documentElement.dataset.theme = kayitli || 'light';
} catch (e) { /* private mode: the default light theme stays */ }
})();
</script>
Two details: in a private tab, reading localStorage can throw, so the try/catch is not optional. And whenever the theme changes I also update the content of meta[name="theme-color"], which keeps the address bar color in a mobile browser in step with the page.
When there is no saved preference, you can fall back to the system theme with matchMedia('(prefers-color-scheme: dark)'). On this site I deliberately left light as the default, because a reader on the hiring side is usually opening the site for the first time and during the day.
Grid or Flexbox?
These days I answer that question with a single sentence: am I deciding the layout, or is the content? If I know the rows and columns up front, Grid; if I want items to line up side by side as long as they fit and drop to the next line when they don't, Flexbox.
| Case | Choice | Why |
|---|---|---|
| Page skeleton, two-column hero | Grid | I define the column ratios myself |
| Card list | Grid | Cards have to stay the same width and aligned |
| Tag/badge strip | Flexbox | Width follows the content, wraps to the next line when it overflows |
| Button group, meta row on top of a card | Flexbox | A single axis, a variable number of items |
| Centering one element perfectly | Grid | place-items: center, one line |
The two are not rivals; they nest inside each other. The blog card list is Grid, and the date–category–reading time row inside the card is Flexbox.
auto-fit, auto-fill and minmax()
One line is enough for a responsive card grid. This is what I use on the blog listing:
.yazilar {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
What it says: let a card be at least 320 pixels wide, open as many columns as fit, and share the leftover space out with 1fr. No media queries, no breakpoints. The difference between auto-fill and auto-fit only shows up when there are few items:
- auto-fill: creates every column that fits in the row and leaves the ones it cannot fill empty. With three posts the cards stay left-aligned at their natural width.
- auto-fit: collapses the empty columns to zero, and the existing items share the remaining space. With three posts the cards become enormous.
I went with auto-fill on the blog listing, because I don't want to see a screen-wide card in a category that only has one post. In the four-box summary strip at the top of pages, where the number of items is fixed, auto-fit works better.
min-width: 0 — the two words that cost me the most time
The hero section of the home page is a two-column Grid with a code card in the right column. Because of the long lines inside that card, the page started scrolling horizontally; the right column was overflowing its container. I fought with widths, with overflow: hidden, even with fixed pixel values. The problem was something else entirely.
Grid and Flex items get min-width: auto by default, which means they cannot be narrower than their own content. A long, unwrapped line of code was setting the column's minimum width to its own length. And 1fr actually means minmax(auto, 1fr), so it doesn't break that rule either.
.hero-inner {
display: grid;
grid-template-columns: 1.15fr .85fr;
gap: clamp(32px, 5vw, 64px);
}
/* Let items get narrower than their content:
the code card now scrolls inside itself. */
.hero-inner > * { min-width: 0; }
The same problem shows up a second time with fixed columns. In the four-box stats strip, writing repeat(4, 1fr) let one long heading make the columns uneven; with repeat(4, minmax(0, 1fr)) I got four genuinely equal columns. Text truncation (text-overflow: ellipsis) doesn't work at all without this fix either — and the same goes for Flex rows.
Don't reach for overflow: hidden first; it only hides the symptom. Find the overflowing element in developer tools and apply min-width: 0 to the Grid/Flex parents up the chain. That was the cause in nearly every case I ran into.
Fluid typography with clamp(), and a mistake I fixed
Thanks to clamp(min, preferred, max) I don't write separate media queries for heading sizes. It works for spacing too; my page gutter is a single line:
:root { --gutter: clamp(20px, 5vw, 48px); }
/* First version — the middle value is pure vw */
h2 { font-size: clamp(1.75rem, 4vw, 2.75rem); }
/* Fixed version — a rem base was added */
h2 { font-size: clamp(1.75rem, 1.1rem + 2.6vw, 2.75rem); }
The difference between the two is accessibility. If you write the middle value with vw alone, the font size depends only on the window width; when the user increases the browser's font size, the heading doesn't budge. The WCAG resize-text criterion requires content not to be lost when it is scaled up to 200%. Once you add a rem base to the middle value, the heading both flows with the screen and responds to the user's preference. I used the first version while writing this site and later switched the headings to the one with a base value.
I also decided not to make the body text fluid with clamp(). A fixed rem value for paragraph size and a line length of around max-width: 70ch reads better than a fluid size.
Contrast: the easiest thing to miss in dark mode
The most common trap when building a dark theme is dimming gray text too far. WCAG level AA asks for a contrast ratio of 4.5:1 for normal text and 3:1 for large text. There is a separate criterion for the boundaries of user interface components and graphics, and there the threshold is 3:1.
My --text-dim value was far too dark in dark mode at first; small text such as the date and the reading time fell below the threshold against the #08080D background. I lightened the value to #8A8AA0. Colored badges caused trouble in light mode as well: a light green “live” label looks nice on white but was unreadable, so in light mode I darkened the badge text and pulled the background to a translucent green.
My practical method is this: I don't guess when picking a color — the color picker in the browser's developer tools shows the contrast ratio directly. You only need to separate two cases: a decorative divider line does not have to hit 3:1, but the border of a card or a form field does if that line is the only thing that defines it.
Finally, never remove the focus ring. Writing outline: none and putting nothing in its place leaves a keyboard user blind on the page. Wherever I remove the default, I draw my own ring with :focus-visible.
Wrapping up
Six things that worked on this site: naming variables after roles rather than colors, applying the theme before the first paint, picking Grid when I set the layout and Flexbox when the content does, building card grids with auto-fill + minmax() and no media queries, trying min-width: 0 first whenever I see overflow, and choosing colors by measuring contrast instead of by eye.
None of this is new knowledge; it is all in the documentation. But I only really learned why the min-width: 0 line is needed after my own page scrolled sideways for two days. When layout doesn't work in CSS, what's usually missing isn't a property — it's being unaware of a default behavior.
- CSS
- Grid
- Dark Mode
- Accessibility
- Responsive Design
- Web
Demir Taşdemir
Mobile App & Web Developer
I have been building software since 2018. I have released 11 apps on the App Store and Google Play; right now I am working on 6 mobile apps, 1 e-commerce platform and 1 desktop game.
Is your site's interface due for an overhaul?
I build websites, including the theme system, responsive layout and accessibility fixes. Let's talk about your project.