UI/UX Design Module 6 – Visual design for interfaces
Visual hierarchy, layout and grids
Make one MealBox action lead a screen with size, weight, colour and space, put gaps on a scale and edges on a column grid, and check it with a blur test.
What you will learn
- Create a clear hierarchy with size, weight, colour, contrast and space
- Arrange a screen on a column grid with a spacing scale
- Check that the order of the code matches the order of the screen
- Run a blur test and read its ranking
Before you start
What you’ll learn · Before you start
What you will learn
- Create a clear hierarchy with size, weight, colour, contrast and space
- Arrange a screen on a column grid with a spacing scale
- Check that the order of the code matches the order of the screen
- Run a blur test and read its ranking
Before you start
On this page
MealBox’s plan page, drawn for this lesson, has eight blocks in one 16 px regular style at the same contrast ratio of 10.31:1. Nothing leads: in a blur test of the page, the Choose plan button ranks sixth of eight and the feature list ranks first. All the numbers in this lesson come from layouts invented for it.
visual hierarchy: The order in which a screen asks to be read, set by differences in size, weight, colour, contrast, space and position between its elements. is the order in which a screen asks to be read, and a handful of levers set it. You will rank elements with those levers, put every gap on a spacing scale and every left edge on a column grid, keep the code in the order of the screen, and check the result with a blur test that runs as a program.
Five levers decide what a screen leads with
Nielsen Norman Group names three ways to build a visual hierarchy: colour and contrast, scale, and grouping. This lesson splits them into five levers that a layout can measure: size, weight, colour (a text colour or a fill), contrast against the background, and space around the element. Position adds a sixth. Apple’s layout guidance notes that people start reading from the top and the leading side, so the most important item belongs there.
| Lever | What it does | Watch for |
|---|---|---|
| Size | Bigger elements are noticed first | Many large elements cancel each other |
| Weight | Heavier type reads as more important | Bold everywhere is no emphasis |
| Colour | A fill or an accent colour marks one element | Colour must not carry meaning alone |
| Contrast | Strong contrast draws the eye | Text still needs 4.5:1 at the least |
| Space | Room around an element sets it apart | Uneven gaps look accidental |
Step through the plan page and add the levers one at a time. The sub-labels show the values the program below prints:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A flat page ranks nothing. Eight blocks, all 16 px regular in one dark grey at a contrast ratio of 10.31:1. No block differs from the body style by any lever, so the eye has no place to start and the price looks as important as the help link.
- Size sets the top of the ranking. The page title and the price go to 32 px and the plan name to 20 px, which makes three sizes in all. The buttons stay at 16 px: size is kept for the content people read first, not for the actions.
- Weight separates the two actions. The page title and the price take weight 700 and the plan name 600. The primary action takes 600 and the secondary 500, so both stay readable and the primary one is plainly heavier.
- Colour marks one primary action. Only Choose plan gets a filled button, white on blue at 6.70:1. Compare plans is blue text without a fill, also 6.70:1, and the tag has a pale blue fill. Colour here supports the weight and the words, never replaces them.
- Space opens each new group. Extra space above the page title, the plan name, the primary button and the help link starts a new group at each of them. The gaps come from a scale, which the next section builds, instead of from guesses.
- The primary action leads. Choose plan now differs from the body style by three levers and Compare plans by one. Five blocks differ by two or more levers, against none before, and the page uses three font sizes.
The program measures the same eight blocks in two versions. A block counts a lever when it differs from the body style: a size at least 1.25 times the body, a weight at least 200 higher, a different colour or a fill, or twice the body’s space above. It also prints each block’s contrast ratio, worked out with the WCAG formula.
# MealBox plan page, drawn for this lesson: two versions of the same eight elements (invented values).
# A "lever" is a way an element differs from the body style: size, weight, colour or fill, space above.
def lum(h):
c = [int(h[i:i + 2], 16) / 255 for i in (1, 3, 5)]
c = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c]
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
def ratio(a, b):
hi, lo = sorted((lum(a), lum(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
BODY = (16, 400, "#374151", None, 12) # size px, weight, text colour, fill, space above px
# name: (size, weight, text colour, fill, space above)
FLAT = {
"Page title": (16, 400, "#374151", None, 12), "Plan name": (16, 400, "#374151", None, 12),
"Price": (16, 400, "#374151", None, 12), "Recommended tag": (16, 400, "#374151", None, 12),
"Choose plan (primary)": (16, 400, "#374151", None, 12), "Compare plans (secondary)": (16, 400, "#374151", None, 12),
"Feature line": (16, 400, "#374151", None, 12), "Help link": (16, 400, "#374151", None, 12),
}
RESTYLED = dict(FLAT)
RESTYLED.update({
"Page title": (32, 700, "#111827", None, 32), "Plan name": (20, 600, "#111827", None, 24),
"Price": (32, 700, "#1D4ED8", None, 12), "Recommended tag": (16, 600, "#1E3A8A", "#DBEAFE", 12),
"Choose plan (primary)": (16, 600, "#FFFFFF", "#1D4ED8", 24),
"Compare plans (secondary)": (16, 500, "#1D4ED8", None, 12), "Help link": (16, 400, "#374151", None, 48),
})
def levers(style):
size, weight, colour, fill, space = style
found = []
if size >= BODY[0] * 1.25: found.append("size")
if weight >= BODY[1] + 200: found.append("weight")
if fill or colour != BODY[2]: found.append("colour")
if space >= BODY[4] * 2: found.append("space")
return found
def report(title, page):
print(title)
print(f"{'element':27}{'size':>5}{'wt':>5}{'contrast':>10} levers")
for name, s in page.items():
c = ratio(s[2], s[3] or "#FFFFFF")
print(f"{name:27}{s[0]:>5}{s[1]:>5}{c:>9.2f}:1 {', '.join(levers(s)) or '-'}")
sizes = sorted({s[0] for s in page.values()})
strong = [n for n, s in page.items() if len(levers(s)) >= 2]
print(f"font sizes in use: {sizes}; elements with 2+ levers: {len(strong)}\n")
report("Flat page", FLAT)
report("Restyled page", RESTYLED)
primary = len(levers(RESTYLED["Choose plan (primary)"]))
secondary = len(levers(RESTYLED["Compare plans (secondary)"]))
print(f"primary action levers: {primary}, secondary: {secondary}") Output
Flat page element size wt contrast levers Page title 16 400 10.31:1 - Plan name 16 400 10.31:1 - Price 16 400 10.31:1 - Recommended tag 16 400 10.31:1 - Choose plan (primary) 16 400 10.31:1 - Compare plans (secondary) 16 400 10.31:1 - Feature line 16 400 10.31:1 - Help link 16 400 10.31:1 - font sizes in use: [16]; elements with 2+ levers: 0 Restyled page element size wt contrast levers Page title 32 700 17.74:1 size, weight, colour, space Plan name 20 600 17.74:1 size, weight, colour, space Price 32 700 6.70:1 size, weight, colour Recommended tag 16 600 8.49:1 weight, colour Choose plan (primary) 16 600 6.70:1 weight, colour, space Compare plans (secondary) 16 500 6.70:1 colour Feature line 16 400 10.31:1 - Help link 16 400 10.31:1 space font sizes in use: [16, 20, 32]; elements with 2+ levers: 5 primary action levers: 3, secondary: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hierarchy_levels.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The flat page has no block with any lever and one font size. The restyled page has three sizes, 16, 20 and 32 px, which keeps to Nielsen Norman Group’s advice of no more than three sizes. Choose plan differs from the body by three levers and Compare plans by one, so the two actions are plainly ranked while both stay readable at 6.70:1. The counting rule is this lesson’s own, not a published standard; what it shows is a ranking you can read off numbers.
A secondary action nobody can see
Wrong: Compare plans in pale grey 16 px text, 2.54:1 on white, so the second choice looks like decoration and fails the 4.5:1 of WCAG 2.2 success criterion 1.4.3. Right: the same weight of text in the brand blue at 6.70:1, with no fill. The secondary action should look secondary, not missing.
Each lever is cheap to add and easy to overuse, which is why the ranking comes before the decoration.
Key idea
Rank the blocks before you style them: give the one primary action the most levers, give the secondary action fewer, and keep every text colour above 4.5:1.
Checkpoint
Check: The levers of visual hierarchy
A spacing scale gives every gap a reason
On the flat page the gaps were chosen by eye: 32, 8, 12, 18, 13 and 48 px. A spacing scale: A short list of allowed gaps, for example 4, 8, 12, 16, 24, 32 and 48 px, from which every margin and padding in a design is taken. is a short list of allowed gaps, and every margin and padding is taken from it. This lesson uses 4, 8, 12, 16, 24, 32 and 48 px as its example. GOV.UK’s design system publishes another: its static scale has the units 5, 10, 15, 20, 25, 30, 40, 50 and 60 px, and its responsive scale makes the larger units smaller on screens narrower than its tablet breakpoint of 640 px.
A scale does two jobs. It makes the page consistent, because a gap of 24 px always means the same degree of separation. It also keeps groups readable: in the lesson on perception, spacing alone can show a group when the smallest gap between groups is double the largest gap within one. Guessed gaps break that rule without anyone noticing. The program measures the six gaps of the plan page, snaps each to the nearest scale value and tests the 2x rule before and after.
# A spacing scale checked against the vertical gaps of a MealBox plan page drawn for this lesson (invented values).
SCALE = [4, 8, 12, 16, 24, 32, 48] # this lesson's example scale, in px
# gap above each block (px), in page order, with the group it starts or continues
GAPS = [("title to cards", 32, "new group"), ("name to price", 8, "same group"), ("price to features", 12, "same group"),
("features to button", 18, "same group"), ("button to link", 13, "same group"), ("cards to help", 48, "new group")]
def snap(gap):
return min(SCALE, key=lambda s: (abs(s - gap), s))
print("gap px on the scale snapped")
for name, gap, _ in GAPS:
print(f"{name:20}{gap:>3} {'yes' if gap in SCALE else 'no ':12}{snap(gap):>6}")
snapped = [(name, snap(gap), kind) for name, gap, kind in GAPS]
def ratio(gaps):
within = max(g for _, g, k in gaps if k == "same group")
between = min(g for _, g, k in gaps if k == "new group")
return within, between, between / within
print("\ndistinct gap values:", len({g for _, g, _ in GAPS}), "->", len({g for _, g, _ in snapped}), "after snapping")
for label, gaps in (("as drawn", GAPS), ("snapped", snapped)):
within, between, r = ratio(gaps)
print(f"{label:9} largest gap inside a group {within}, smallest between groups {between}, ratio {r:.2f}, 2x rule met: {r >= 2}") Output
gap px on the scale snapped title to cards 32 yes 32 name to price 8 yes 8 price to features 12 yes 12 features to button 18 no 16 button to link 13 no 12 cards to help 48 yes 48 distinct gap values: 6 -> 5 after snapping as drawn largest gap inside a group 18, smallest between groups 32, ratio 1.78, 2x rule met: False snapped largest gap inside a group 16, smallest between groups 32, ratio 2.00, 2x rule met: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 spacing_audit.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Two gaps were off the scale, 18 and 13 px. As drawn, the largest gap inside a group is 18 px and the smallest between groups is 32 px, a ratio of 1.78, short of 2. Snapped to 16 and 12, the ratio is exactly 2.00 and the groups read from spacing alone. The six values also became five, which is the point of a scale: fewer distinct gaps, each with a job.
The amount of whitespace: The empty space around and between elements. It is a structural part of a layout that separates groups and gives elements room, not leftover area. on the scale depends on the task. A dashboard that an operator scans all day can sit at the lower end of the scale; an onboarding screen can sit at the upper end. Whitespace is a structural part of the layout, not what is left over.
Break the 2x rule on purpose
Press Edit and change the 18 in the “features to button” row to 28. The as-drawn ratio drops to 1.14 and even the snapped one is 1.33, because 28 snaps to 24 and a gap inside a group is now almost as large as the gap between groups.
Checkpoint
Check: A spacing scale and the 2x rule
Column grids give every edge a place to align
On the 1024 px version of the page, the Compare link starts 6 px to the right of the plan cards and the Help link 8 px to the left of them. The text still reads, but the page looks untidy: Apple’s guidance says people assume aligned items are related and read indented items as subordinate, so a stray edge says something false.
A column grid: A layout divided into equal columns with gutters between them and a margin on each side, so that every block starts at a column edge. fixes the edges. It divides the width into equal columns separated by gutter: The fixed gap between two neighbouring columns of a grid. Column width is what remains of the width after the margins and the gutters, divided by the number of columns., with a margin at each side, and every block starts at a column edge. The column width follows from the other numbers:
At 1024 px with 12 columns, 32 px margins and 24 px gutters, that is (1024 − 64 − 264) ÷ 12 = 58 px. Three plan cards of 4 columns each start at x = 32, 360 and 688 px. The number of columns changes with the width. Android’s documentation sorts windows into five window size class: A named range of window width, such as compact below 600 or medium from 600 to 839, used to choose a layout. It describes the window, not the device, and can change while an app runs. by width, and says they describe the window, not the device, so the class can change while the app runs.
- Compact
Under 600 px wide 4 columns
Phones in portrait and narrow windows. The lesson's grid has 4 columns of 70 px with 16 px margins and gutters at 360 px, and a single column of content is the usual layout.
- Medium
600 to 839 px wide 8 columns
Tablets in portrait and split windows. The lesson's grid has 8 columns of 74 px with 32 px margins and 16 px gutters at 768 px. Two panes become possible here.
- Expanded
840 to 1199 px wide 12 columns
Tablets in landscape and small desktop windows. The lesson's grid has 12 columns of 58 px with 32 px margins and 24 px gutters at 1024 px, so three plan cards take 4 columns each.
- Large
1200 to 1599 px wide
Large tablet displays and desktop windows. This step and the next were added to the Android classes to suit desktop and connected displays, where a layout has even more room to use.
- Extra large
1600 px wide and up
Desktop displays. Content usually stops growing here: GOV.UK, for instance, sets a maximum page width of 1020 px by default so that lines of text stay short.
The timeline has 5 events, from Compact to Extra large.
The program builds the lesson’s three grids and checks where five blocks begin.
# A column grid applied to a MealBox plan page drawn for this lesson (invented values).
# Width classes follow Android's window size classes (in dp, read here as CSS px): compact < 600, medium < 840,
# expanded < 1200, large < 1600, extra-large from 1600.
GRIDS = {360: (4, 16, 16), 768: (8, 32, 16), 1024: (12, 32, 24)} # width: (columns, margin, gutter)
def width_class(w):
for limit, name in ((600, "compact"), (840, "medium"), (1200, "expanded"), (1600, "large")):
if w < limit:
return name
return "extra-large"
def column_starts(width):
cols, margin, gutter = GRIDS[width]
col = (width - 2 * margin - (cols - 1) * gutter) / cols
return col, [margin + i * (col + gutter) for i in range(cols)]
print("width class cols margin gutter column")
for w, (cols, margin, gutter) in GRIDS.items():
col, _ = column_starts(w)
print(f"{w:>5} {width_class(w):9}{cols:>4}{margin:>7}{gutter:>7}{col:>7.1f}")
# left edges (px) of the page's blocks at width 1024
EDGES = {"Plan card 1": 32, "Plan card 2": 360, "Plan card 3": 688, "Compare link": 366, "Help link": 40}
col, starts = column_starts(1024)
print("\nleft edges at 1024 px (column starts:", ", ".join(f"{s:.0f}" for s in starts[:9]) + ", ...)")
for name, left in EDGES.items():
nearest = min(starts, key=lambda s: abs(s - left))
off = abs(nearest - left)
print(f"{name:13}{left:>5} {'on the grid' if off <= 1 else f'off by {off:.0f} px (column starts at {nearest:.0f})'}") Output
width class cols margin gutter column 360 compact 4 16 16 70.0 768 medium 8 32 16 74.0 1024 expanded 12 32 24 58.0 left edges at 1024 px (column starts: 32, 114, 196, 278, 360, 442, 524, 606, 688, ...) Plan card 1 32 on the grid Plan card 2 360 on the grid Plan card 3 688 on the grid Compare link 366 off by 6 px (column starts at 360) Help link 40 off by 8 px (column starts at 32)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 grid_audit.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The three cards sit on column starts. The Compare link at 366 px is 6 px past the column that starts at 360, and the Help link at 40 px is 8 px past 32. A tolerance of 1 px is plenty: anything larger is a visible drift. GOV.UK’s grid takes the other route to the same result, with columns of whole fractions, and tells designers to put the main content in a two-thirds column. MySmartCoPilot’s layout grid calculator works out column widths for your own numbers, and the CSS grid generator writes the code for a grid.
Key idea
A column grid is one formula: width, margins, gutters and column count give the column width, and every block starts at a column edge.
Checkpoint
Check: Column grids, margins and gutters
The code must read in the order of the screen
MealBox wants the recommended Family pack plan to be the first card on screen, so a CSS rule moves it to the front while the code still lists it fourth. Sighted visitors see the recommended plan first. People who tab through the page or use a screen reader meet the cards in the order of the code. WCAG 2.2 criterion 1.3.2 asks for a correct reading sequence to be programmatically available where the order affects meaning, and criterion 2.4.3 asks for focus to move in an order that preserves meaning and operability. The W3C lists making the DOM order match the visual order as a technique for both. In other words, a reading order: The sequence in which content is read by a screen reader and reached by keyboard, taken from the order of the code. It should match the order on screen wherever the order carries meaning. that differs from the screen is a fault to check for.
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The code lists the blocks. Keyboard and screen reader users meet the blocks in the order of the code. Here the recommended Family pack is fourth, because the page was built with the cards in the order of a database table.
- A style rule moves one card. A CSS order rule puts the Family pack first among the cards, at 1024 px and at 360 px alike. Sighted visitors see the recommended plan before the other two.
- Three blocks sit apart. The dashed arrows cross: positions 2, 3 and 4 hold different blocks in the code and on the screen. Tabbing from the title lands on Weekday lunch, the card that comes after the recommended plan on screen.
- Move the code, not the style. Put the Family pack second in the code and delete the order rule. The code now lists Title, Family pack, Weekday lunch, Weekend meals and Help, the same order as the screen at both widths, and the example prints no position that differs.
The program lists the five blocks by position on screen, with a rule that blocks within 16 px of each other in height share a row and read from left to right, and compares that with the order of the code at 1024 and 360 px.
# Does the order in the code match the order on screen? A MealBox plan page drawn for this lesson (invented positions).
# Each block: (top px, left px). DOM order is the order of the list, which keyboard and screen reader users follow.
WIDE = {"Title": (24, 32), "Family pack": (96, 32), "Weekday lunch": (96, 360), "Weekend meals": (96, 688), "Help": (480, 32)}
NARROW = {"Title": (24, 16), "Family pack": (88, 16), "Weekday lunch": (280, 16), "Weekend meals": (472, 16), "Help": (664, 16)}
def visual_order(layout, band=16):
# rows read top to bottom; blocks whose tops lie within one band share a row and read left to right
rows = []
for name, (top, left) in sorted(layout.items(), key=lambda kv: kv[1]):
if rows and top - rows[-1][0] < band:
rows[-1][1].append((left, name))
else:
rows.append((top, [(left, name)]))
return [name for _, row in rows for _, name in sorted(row)]
def check(label, dom):
print(f"{label}: code order {' > '.join(dom)}")
for width, layout in (("1024 px", WIDE), ("360 px", NARROW)):
seen = visual_order(layout)
apart = [i + 1 for i, name in enumerate(dom) if seen[i] != name]
print(f" {width}: screen order {' > '.join(seen)}")
print(f" positions that differ: {apart if apart else 'none'}")
# "Family pack" is the recommended plan: a CSS order rule moves it first on screen while it stays third in the code.
check("Before", ["Title", "Weekday lunch", "Weekend meals", "Family pack", "Help"])
check("After ", ["Title", "Family pack", "Weekday lunch", "Weekend meals", "Help"]) Output
Before: code order Title > Weekday lunch > Weekend meals > Family pack > Help
1024 px: screen order Title > Family pack > Weekday lunch > Weekend meals > Help
positions that differ: [2, 3, 4]
360 px: screen order Title > Family pack > Weekday lunch > Weekend meals > Help
positions that differ: [2, 3, 4]
After : code order Title > Family pack > Weekday lunch > Weekend meals > Help
1024 px: screen order Title > Family pack > Weekday lunch > Weekend meals > Help
positions that differ: none
360 px: screen order Title > Family pack > Weekday lunch > Weekend meals > Help
positions that differ: none
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 reading_order.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Before the fix, positions 2, 3 and 4 differ at both widths. After moving the Family pack up in the code and deleting the order rule, no position differs. WCAG is careful about its scope: it asks for a meaningful sequence only where the order carries meaning, and it allows the focus order to differ from the reading order when both preserve meaning. Recommended-plan-first carries meaning here, since it is the plan the page asks people to consider first. Fix the code rather than the style, because the code is the order everyone gets.
Checkpoint
Check: Code order against screen order
A blur test shows which block the eye reaches first
The lesson on perception introduced the blur test: soften a screenshot, or squint, until text cannot be read, and see which elements still stand out. Nielsen Norman Group’s example blurred a music app’s screen by 5, 10 and 20 px. The two softer blurs confirmed the groups, but at 20 px a single tile in a vivid colour outranked the section that was meant to lead. Content can break a hierarchy that the template got right.
The program turns the test into arithmetic. It draws the plan page on a grid of 8 px cells, one value per cell from the element’s colour and how much of its box is ink, blurs the grid with a box filter, and scores each block by how much darker than the page it stays. The page layouts are invented, and a box blur is a rough stand-in for squinting, so read the ranks, not the decimals.
# A blur test as arithmetic: draw a 360 x 640 px MealBox plan page on a grid of 8 px cells, blur it, rank the elements.
# Invented layout; a box blur is a rough stand-in for squinting, not a model of vision.
W, H, CELL = 45, 80, 8
BG = 1.0 # white page, relative luminance 1
def lum(h):
c = [int(h[i:i + 2], 16) / 255 for i in (1, 3, 5)]
c = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in c]
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
# name: (x, y, width, height, colour, coverage); coverage = share of the box that is ink (text 0.35, a fill 1.0)
def page(restyled):
t = "#374151"
if not restyled:
return {"Page title": (16, 24, 128, 20, t, .35), "Plan name": (16, 88, 104, 20, t, .35),
"Price": (16, 120, 56, 20, t, .35), "Recommended tag": (200, 92, 96, 20, t, .35),
"Feature lines": (16, 168, 328, 56, t, .35), "Choose plan (primary)": (16, 248, 96, 20, t, .35),
"Compare plans": (16, 312, 112, 20, t, .35), "Help link": (16, 600, 40, 20, t, .35)}
return {"Page title": (16, 24, 224, 40, "#111827", .5), "Plan name": (16, 88, 128, 24, "#111827", .45),
"Price": (16, 120, 120, 40, "#1D4ED8", .5), "Recommended tag": (200, 92, 120, 24, "#DBEAFE", 1.0),
"Feature lines": (16, 168, 328, 56, t, .35), "Choose plan (primary)": (16, 248, 328, 48, "#1D4ED8", 1.0),
"Compare plans": (16, 312, 112, 24, "#1D4ED8", .4), "Help link": (16, 600, 40, 20, t, .35)}
def blur(grid, r):
out = [[0.0] * W for _ in range(H)]
for y in range(H):
for x in range(W):
cells = [grid[j][i] for j in range(max(0, y - r), min(H, y + r + 1)) for i in range(max(0, x - r), min(W, x + r + 1))]
out[y][x] = sum(cells) / len(cells)
return out
def rank(elements, r):
grid = [[BG] * W for _ in range(H)]
for x, y, w, h, colour, cover in elements.values():
for j in range(y // CELL, (y + h + CELL - 1) // CELL):
for i in range(x // CELL, (x + w + CELL - 1) // CELL):
grid[j][i] = BG + cover * (lum(colour) - BG)
soft = blur(grid, r)
score = {}
for name, (x, y, w, h, _, _) in elements.items():
cells = [soft[j][i] for j in range(y // CELL, (y + h + CELL - 1) // CELL) for i in range(x // CELL, (x + w + CELL - 1) // CELL)]
score[name] = BG - sum(cells) / len(cells) # how much darker than the page the element stays
return sorted(score, key=score.get, reverse=True), score
for label, restyled in (("Flat page", False), ("Restyled page", True)):
for r in (1, 2):
order, score = rank(page(restyled), r)
top = ", ".join(f"{n} {score[n]:.2f}" for n in order[:3])
print(f"{label}, blur radius {r * CELL} px: {top}; primary action ranks {order.index('Choose plan (primary)') + 1} of {len(order)}") Output
Flat page, blur radius 8 px: Feature lines 0.30, Page title 0.25, Compare plans 0.25; primary action ranks 6 of 8 Flat page, blur radius 16 px: Feature lines 0.27, Plan name 0.19, Price 0.19; primary action ranks 7 of 8 Restyled page, blur radius 8 px: Choose plan (primary) 0.78, Page title 0.42, Price 0.37; primary action ranks 1 of 8 Restyled page, blur radius 16 px: Choose plan (primary) 0.69, Page title 0.36, Price 0.34; primary action ranks 1 of 8
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blur_test.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
On the flat page the largest block of text, the feature lines, leads, and the Choose plan button ranks sixth of eight at a blur of 8 px and seventh at 16 px. On the restyled page the filled button ranks first at both radii, at 0.78 and 0.69, ahead of the page title. The result checks the design: the hierarchy built with levers survives a blur.
Weaken the button on purpose
Press Edit and change the colour in the “Choose plan (primary)” entry of the restyled page, the one 328 px wide, from “#1D4ED8” to “#DBEAFE”, a pale fill. The primary action drops to seventh of eight at both blurs, below the title, the price and the plan name. A lever can be spent and lost in a single value.
The test has limits. It ranks what you drew, not what real content will do, and it says nothing about whether the labels are clear, so it sits beside the five-second test and a task-based usability test rather than replacing them.
Checkpoint
Check: Reading a blur test
Practise
The plan page below has eight blocks that share one style, gaps off the scale, three different left edges and the recommended plan third in the code. Edit only the numbers to meet five rules, then check them against the sample tests:
Exercise · Easy · Python
Restyle a flat MealBox plan page
A MealBox plan page has eight blocks, all set in the same 16 px regular type, with random gaps, uneven left edges and the recommended plan third in the code. Edit the values of BLOCKS in solution.py so that:
- Three sizes at most. The page uses at most 3 different
font_sizevalues. The page title and the price are at least 1.5 times the body size (16 px), and the plan name is larger than the body text. - One primary action. "Choose plan" has
weight600 or more and "Compare plans" has a lower weight, so the secondary action looks secondary and is still visible. - Spacing from the scale. Every
space_aboveis a value ofSCALE(4, 8, 12, 16, 24, 32 or 48). - One left edge. Every
leftis within 1 px of every other. - Code order matches screen order. Sorting the blocks by
topmust give them in ascendingdom_index, the position of each block in the page's code (0 comes first). The recommended plan is first on screen, so it comes first in the code.
Change only the numbers. Do not add or remove blocks.
Starter code · solution.py
"""
MealBox plan page: eight blocks drawn for this exercise (invented values).
Edit the numbers in BLOCKS so that the page has a clear hierarchy:
- at most 3 font sizes; title and price at least 1.5 times the body size; plan name larger than the body
- "Choose plan" at weight 600 or more, "Compare plans" at a lower weight
- every space_above from SCALE
- every left edge within 1 px of the others
- blocks sorted by top come out in ascending dom_index
"""
from dataclasses import dataclass
SCALE = [4, 8, 12, 16, 24, 32, 48]
BODY_SIZE = 16
@dataclass
class Block:
name: str
font_size: int # px
weight: int # 400 regular, 500 medium, 600 semibold, 700 bold
space_above: int # px
left: int # px
top: int # px, where the block sits on screen
dom_index: int # position in the page's code, 0 first
BLOCKS = [
Block("Page title", 16, 400, 13, 16, 24, 3),
Block("Plan name", 16, 400, 7, 17, 88, 5),
Block("Price", 16, 400, 20, 16, 120, 6),
Block("Recommended tag", 16, 400, 5, 19, 152, 7),
Block("Choose plan", 16, 400, 18, 16, 184, 0),
Block("Compare plans", 16, 400, 18, 22, 248, 1),
Block("Feature line", 16, 400, 9, 16, 296, 2),
Block("Help link", 16, 400, 30, 16, 520, 4),
] The sample tests · test_solution.py
"""Sample tests for the plan-page-hierarchy exercise."""
from solution import BLOCKS, BODY_SIZE, SCALE
BY_NAME = {b.name: b for b in BLOCKS}
def test_eight_blocks_with_the_given_names():
assert [b.name for b in BLOCKS] == [
"Page title", "Plan name", "Price", "Recommended tag",
"Choose plan", "Compare plans", "Feature line", "Help link",
]
def test_at_most_three_font_sizes():
sizes = {b.font_size for b in BLOCKS}
assert len(sizes) <= 3, f"{len(sizes)} sizes in use: {sorted(sizes)}"
def test_title_and_price_are_large():
for name in ("Page title", "Price"):
assert BY_NAME[name].font_size >= 1.5 * BODY_SIZE, f"{name} must be at least {1.5 * BODY_SIZE:g} px"
def test_plan_name_is_larger_than_body():
assert BY_NAME["Plan name"].font_size > BODY_SIZE
def test_primary_action_outweighs_secondary():
primary, secondary = BY_NAME["Choose plan"].weight, BY_NAME["Compare plans"].weight
assert primary >= 600, "the primary action needs weight 600 or more"
assert secondary < primary, "the secondary action must have a lower weight than the primary"
assert secondary >= 400, "the secondary action must stay readable"
def test_spacing_comes_from_the_scale():
off = [(b.name, b.space_above) for b in BLOCKS if b.space_above not in SCALE]
assert not off, f"off the scale: {off}"
def test_one_left_edge():
lefts = [b.left for b in BLOCKS]
assert max(lefts) - min(lefts) <= 1, f"left edges span {max(lefts) - min(lefts)} px"
def test_code_order_matches_screen_order():
by_top = sorted(BLOCKS, key=lambda b: b.top)
indexes = [b.dom_index for b in by_top]
assert indexes == sorted(indexes), f"code order {indexes} differs from screen order"
assert sorted(indexes) == list(range(len(BLOCKS))), "dom_index must use 0 to 7 once each" A hint
Start with the code order. The top values already give the screen order: the page title, the plan name and the price come first, then the tag, the buttons and the feature line, and the help link comes last. Give the dom_index values the same order, 0 to 7, so that sorting by top and by dom_index agree. Then take sizes, weights, spacing and edges one rule at a time.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the lesson’s questions, from the levers to the blur test. The first one comes from the concept where your answers have been weakest:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: how do you decide which action on a screen is the primary one?
Start from the task the screen exists for, not from what the business would like to be clicked. The primary action is the step that moves the person’s main task forward on this screen: Choose plan on a plan page, Submit on a complaint form. There is one per view. Everything else is secondary and styled with fewer levers, an outline or a text style, so that it stays visible and readable. If two actions seem equal, the task is probably two tasks or the screen is doing too much. Back the choice with evidence: ask five people what they would do next, or run a blur test and check that the primary action is among the first things that stand out.
Warm-up: why use a spacing scale instead of choosing each gap by eye?
A scale turns spacing into a small vocabulary. Every gap comes from a short list, so equal separation looks equal across screens, groups stay readable because the gap between groups is a clear step above the gap inside them, and designers and engineers talk about the same values. It also cuts the decisions in a design review: the question becomes which step of the scale, never which number of pixels. The cost is small, because a scale of six or seven steps covers almost every layout, and an exception can be made on purpose and noted.
Two harder questions follow. Decide what you would check first, before you open either first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
On Ward Desk's complaint page, a department head asks for Escalate, Close and Add comment to be filled, same-size primary buttons, "so officers never miss one". Officers use the page forty times a day. What do you do?
-
Mid to staff
A Ward Desk dashboard has three columns. A CSS grid rule puts the Urgent column on the left, though the code lists it last. A keyboard tester says the tab order jumps. What do you check, and what do you change?
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which five levers decide what a screen leads with?
Size, weight, colour, contrast and space, plus position. The one primary action gets more of them than the secondary action, and many large or bold elements cancel each other out.
-
How should a secondary action look next to the primary one?
Secondary, not missing. Pale grey text at 2.54:1 on white fails the 4.5:1 of WCAG 2.2 criterion 1.4.3; the same text in the brand blue at 6.70:1 with no fill stays readable and still looks secondary.
-
What is a spacing scale, and what does it do for a layout?
A short list of allowed gaps, such as 4, 8, 12, 16, 24, 32 and 48 px, from which every margin and padding is taken. Gaps become consistent, and groups stay readable while the smallest gap between groups is at least double the largest gap within one.
-
How do you work out the column width of a column grid?
(width − 2 × margin − (columns − 1) × gutter) ÷ columns. At 1024 px with 12 columns, 32 px margins and 24 px gutters, that is (1024 − 64 − 264) ÷ 12 = 58 px, and every block starts at a column edge.
-
What do Android's window size classes describe?
The window, not the device: five classes sorted by width that set how many columns a layout uses. The class can change while the app runs.
-
CSS moves the recommended plan to the front of the screen while the code lists it fourth. Why is that a fault?
Keyboard and screen reader users meet the cards in the order of the code. WCAG 2.2 criteria 1.3.2 and 2.4.3 ask for a reading and focus order that preserves meaning, so move the card in the code instead of restyling it.
-
What does a blur test rank, and what should it be paired with?
Which block dominates a layout before any text is read. It works best beside a five-second test and a task-based test with people.
Key takeaways
- A visual hierarchy comes from five levers, size, weight, colour, contrast and space, plus position, and a screen needs one primary action that has more of them than the secondary action.
- The secondary action should look secondary, not missing: text still meets 4.5:1 and the page keeps to about three sizes.
- A spacing scale replaces guessed gaps with a short list, and groups stay readable from spacing alone while the smallest gap between groups is at least double the largest gap within one.
- A column grid is one formula, (width − 2 × margin − (columns − 1) × gutter) ÷ columns, blocks that start at column edges look related, and the width class of the window, which can change while the page is open, sets how many columns to use.
- The order of the code is the order that keyboard and screen reader users get, so it should follow the order of the screen wherever the order carries meaning.
- A blur test ranks what dominates a layout before any text is read, and it works best beside a five-second test and a task-based test with people.
Terms in this lesson
- column grid
- A layout divided into equal columns with gutters between them and a margin on each side, so that every block starts at a column edge.
- gutter
- The fixed gap between two neighbouring columns of a grid. Column width is what remains of the width after the margins and the gutters, divided by the number of columns.
- reading order
- The sequence in which content is read by a screen reader and reached by keyboard, taken from the order of the code. It should match the order on screen wherever the order carries meaning.
- spacing scale
- A short list of allowed gaps, for example 4, 8, 12, 16, 24, 32 and 48 px, from which every margin and padding in a design is taken.
- visual hierarchy
- The order in which a screen asks to be read, set by differences in size, weight, colour, contrast, space and position between its elements.
- whitespace
- The empty space around and between elements. It is a structural part of a layout that separates groups and gives elements room, not leftover area.
- window size class
- A named range of window width, such as compact below 600 or medium from 600 to 839, used to choose a layout. It describes the window, not the device, and can change while an app runs.
References
- Visual Hierarchy in UX: Definition (Nielsen Norman Group)
- Layout (Apple)
- Spacing (Government Digital Service (GOV.UK))
- Layout (Government Digital Service (GOV.UK))
- Use window size classes (Android Developers)
- Understanding Success Criterion 1.3.2 Meaningful Sequence (W3C)
- Understanding Success Criterion 2.4.3 Focus Order (W3C)
- Understanding Success Criterion 1.4.3 Contrast (Minimum) (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress