How to Make a Beautiful Website with CSS
Take the working-but-ugly form from the last lesson and make it something you would actually show people — using custom properties, flexbox, grid and a real type scale.
Your form works. It also looks like it was built in 1997. Let us fix that — not with a framework, but by understanding the five ideas that make modern CSS good.
The uncomfortable truth about design: it is rarely about picking prettier colours. It is about consistency and space. Most amateur pages use eleven slightly different greys and fourteen arbitrary margins. Most professional pages use six greys and spacing from a fixed scale. That is the whole secret.
#1. Custom properties — define your system once
:root {
/* Colour */
--brand-50: #f0f7ff;
--brand-100: #dbeeff;
--brand-300: #85c4ff;
--brand-500: #1b84f3;
--brand-600: #0a68d4;
--brand-700: #0a52a8;
--ink: #0f1e33;
--ink-muted: #55657d;
--surface: #ffffff;
--page: #f5f9ff;
--line: #dbe6f5;
/* Spacing — every gap comes from here, nowhere else */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-12: 3rem;
/* Type scale — a fixed ratio, not arbitrary numbers */
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.5rem;
--text-2xl: 2rem;
--text-3xl: 2.75rem;
--radius: 12px;
--shadow: 0 1px 2px rgb(15 30 51 / 0.06),
0 8px 24px -8px rgb(15 30 51 / 0.12);
}Now everything references the system:
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: var(--space-6);
box-shadow: var(--shadow);
}Two payoffs. First, changing the brand colour is one edit instead of forty. Second — and this is the real one — when the only available spacing values are --space-1 through --space-12, you physically cannot introduce a rogue 13px margin. The system enforces the consistency for you.
#2. Sane defaults
Every stylesheet should start with these:
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: var(--text-base);
line-height: 1.6;
color: var(--ink);
background: var(--page);
-webkit-font-smoothing: antialiased;
}
img, svg, video {
display: block;
max-width: 100%;
height: auto;
}
input, button, textarea, select {
font: inherit; /* form controls do NOT inherit fonts by default */
}box-sizing: border-box means width: 300px stays 300px once you add padding and a border. The default (content-box) adds them on top, which is the source of an enormous amount of "why is my layout 32 pixels too wide" frustration.
font: inherit on form controls is the one people forget. Without it, your carefully chosen font stops at the edge of every <input> and the browser substitutes something from 1995.
line-height: 1.6 on body text is the single highest-impact typography change you can make. Cramped lines are the main reason amateur pages feel hard to read.
#3. Flexbox for one dimension
Flexbox arranges things along a line — a row or a column.
.nav {
display: flex;
align-items: center; /* vertical centring, finally solved */
gap: var(--space-6); /* space between, with no margin hacks */
}
.nav__spacer {
margin-left: auto; /* pushes everything after it to the right */
}gap is worth pausing on. Before it existed, spacing children meant margin-right on every item plus a :last-child rule to remove the final one. gap puts space between items only. Never fake it with margins again.
margin-left: auto on a flex child absorbs all remaining space, which is the cleanest way to push a logo left and links right.
Centring anything, in three lines:
.center {
display: flex;
align-items: center; /* cross axis */
justify-content: center; /* main axis */
}#4. Grid for two dimensions
Grid handles rows and columns together. This one line is the most useful in modern CSS:
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
}Read it as: as many equal columns as fit, each at least 280px wide. Three columns on a desktop, two on a tablet, one on a phone — with no media queries at all. The layout responds to available space rather than to guessed device sizes.
For a page shell:
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
max-width: 72rem;
margin-inline: auto;
padding-inline: var(--space-6);
}
@media (min-width: 900px) {
.layout--with-sidebar {
grid-template-columns: 16rem minmax(0, 1fr);
gap: var(--space-12);
}
}#5. Make the form look good
Now the actual payoff. Here is the contact form from the last lesson, restyled:
.form {
display: grid;
gap: var(--space-5, 1.25rem);
max-width: 34rem;
padding: var(--space-8);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.field {
display: grid;
gap: var(--space-2);
}
.field label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--ink-muted);
letter-spacing: 0.01em;
}
.field input,
.field textarea {
width: 100%;
padding: var(--space-3) var(--space-4);
color: var(--ink);
background: var(--brand-50);
border: 1.5px solid var(--line);
border-radius: 10px;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.field input:hover,
.field textarea:hover {
border-color: var(--brand-300);
}
.field input:focus,
.field textarea:focus {
outline: none;
background: var(--surface);
border-color: var(--brand-500);
box-shadow: 0 0 0 4px rgb(27 132 243 / 0.15);
}
.field input[aria-invalid="true"] {
border-color: #e5484d;
background: #fff5f5;
}
.field .error {
font-size: var(--text-sm);
color: #c62a2f;
min-height: 1.25em; /* reserve the space so nothing jumps */
}
.button {
padding: var(--space-3) var(--space-6);
font-weight: 650;
color: white;
background: linear-gradient(180deg, var(--brand-500), var(--brand-600));
border: none;
border-radius: 10px;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, filter 0.15s ease;
box-shadow: 0 1px 2px rgb(10 82 168 / 0.3), 0 6px 16px -6px rgb(10 82 168 / 0.5);
}
.button:hover {
filter: brightness(1.06);
transform: translateY(-1px);
}
.button:active {
transform: translateY(0);
}Four details that separate this from a beginner stylesheet:
Never outline: none on its own. Removing the focus ring without replacing it makes your form unusable for anyone navigating by keyboard. The box-shadow above is the replacement — and it looks better than the default. Removing the indicator entirely is an accessibility bug, not a design choice.
min-height on the error message. Without it, the message appearing pushes everything below it down, and the whole form jumps. Reserving the line means errors fade in without moving anything.
Transition specific properties. transition: all also animates layout properties, which forces the browser to recalculate geometry every frame. Name the properties you actually change.
A 1px lift on hover. translateY(-1px) is barely perceptible consciously, and it makes a button feel physical. Most polish is this small.
#Dark mode is nearly free
Because every colour is a variable, supporting dark mode means redefining seven values:
@media (prefers-color-scheme: dark) {
:root {
--ink: #e8f0fb;
--ink-muted: #94a6bf;
--surface: #131c2b;
--page: #0b1220;
--line: #22304a;
--brand-50: #16233a;
--shadow: 0 1px 2px rgb(0 0 0 / 0.4),
0 8px 24px -8px rgb(0 0 0 / 0.6);
}
}Not one component rule changes. This is the argument for custom properties in a nutshell.
#Respect motion preferences
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Some people get genuinely motion sick from parallax and large transitions. This block is five lines and it is not optional.
#The polish checklist
Before you call any page finished:
- All spacing comes from the scale. No stray
7px. - Body text has
line-heightof at least 1.5 and a measure of 60–75 characters (max-width: 65ch). - Every interactive element has a visible
:focus-visiblestyle. - Text contrast is at least 4.5:1 against its background.
- Hover states exist on everything clickable, and
cursor: pointeron buttons. - It works at 360px wide. Check it.
- No horizontal scrollbar at any width.
prefers-reduced-motionis handled.
Check yourself
4 questions on what you just read. No score is kept — it is only here to catch the bits that did not stick.
-
1What does a fixed spacing scale actually buy you?
Show the answer
A. Rogue values become impossible, so spacing stays consistent by construction.
Most amateur pages use fourteen arbitrary margins. When the only options are
--s-1through--s-24, you cannot introduce a stray 13px. -
2Which line gives you responsive columns with no media queries?
Show the answer
A.
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));Read it as "as many equal columns as fit, each at least 280px". The layout responds to available space rather than to guessed device sizes.
-
3A long URL in one grid column gives the whole page a horizontal scrollbar. Fix?
Show the answer
A. Use
minmax(0, 1fr)instead of1frfor the track.A bare
1frtrack will not shrink below its content's intrinsic width. This is the cause of most mystery horizontal overflow. -
4What is wrong with
outline: noneon a focused input?Show the answer
A. It removes the only cue a keyboard user has about where they are.
Removing the ring is fine — as long as you replace it. A
box-shadowring usually looks better than the default anyway.
#Key takeaways
- Consistency beats decoration. A fixed spacing scale and a small palette do most of the work.
- Custom properties turn a theme change — including dark mode — into a handful of edits.
box-sizing: border-boxandfont: inheriton form controls belong in every reset.- Flexbox for a line, Grid for a plane.
repeat(auto-fit, minmax(280px, 1fr))is responsive with no media queries. - Use
minmax(0, 1fr)to stop long content blowing out grid columns. - Never remove a focus ring without replacing it, and always honour
prefers-reduced-motion.
That completes the PHP and web track. You now have a working, validated, good-looking web application built from nothing.
Finished this one?
The challenge above has no posted solution — that is deliberate. Get it working, then come back for the next lesson.