Sizing and alignment
Icons sized in em beside text and in fixed steps when standalone, optically aligned rather than box-aligned, and always separated from their touch target.
Overview
Icon sizing looks like a detail and is the difference between a glyph that reads as part of a sentence and one that reads as a sticker placed near it. Three things decide it: whether the icon lives in text or on its own, whether it is aligned to its box or to what the eye sees, and whether its size has anything to do with the area a finger has to hit.
The last of those is the one with consequences. An icon's size and its touch target are unrelated quantities. A 20-point glyph inside a 44-point control is correct. A 44-point glyph is a large picture, not a large button.
How it works
In text, size in em. An icon beside a label, inside a sentence, or in a
list row takes its size from the text it accompanies. It then grows with the
reader's text-size multiplier automatically, which is the whole reason for the
rule. A glyph fixed at 16 pixels beside text that has grown to 21 looks
progressively detached and eventually stops reading as part of the same element.
See Dynamic Type.
Standalone, size in fixed steps. An icon that is not in a text context takes one of a small number of fixed sizes from the space scale. There are three: small for inline marks and dense rows, medium for controls and list affordances, large for a category or status mark that is the visual anchor of a tile. The glyph inside an icon-only control and a category mark on a tile are both standalone icons.
Align optically, not to the box. An icon's bounding box and its visual centre are rarely the same, particularly for glyphs with asymmetric ink. An arrow, a play triangle and anything with a tail are all glyphs of that sort. Aligning boxes puts the glyph visibly off centre. The correction is small, per glyph, and it is why the system uses one library: a mixed set means a different correction for every icon and no way to generalise.
Align to the text's optical centre, not to its baseline. A glyph set on the baseline sits low, because the eye centres a symbol against the body of the letterforms rather than against the line they stand on. The right alignment for an icon beside a single line of text is the vertical centre of the x-height and cap height together.
Match stroke weight to text weight. lucide draws with a consistent stroke, and that stroke reads heavier or lighter depending on the weight of the type beside it. A glyph against a bold heading and the same glyph against regular body text need different stroke weights to look like the same object; the mapping is part of the icon role rather than left to each component.
Do not scale a glyph far from its design size. Icons are drawn on a grid, and a set drawn for a small size renders soft or misaligned when blown up several times. That is a second reason the standalone sizes are a fixed short list rather than a free parameter.
Using it
Never grow the icon to grow the target.
Do
A dismiss control at the 44-point floor with a medium glyph centred inside it. The tap area is generous and the glyph is the size a glyph should be.
Don’t
The same control sized to the glyph, so the target is 24 points, or the glyph scaled to 44 so the target is right and the icon looks like a sticker. Size the control and the glyph independently.
Give an icon a gap, and take it from the space scale. The distance between a
glyph and its label is a spacing decision, and hairline or tight is almost
always the answer. See Spacing scale. Too much and
they stop reading as one element; too little and the glyph looks stuck to the
first letter.
Do not mix sizes in a row. A list where each row's icon is sized to its content produces a ragged left edge that the eye reads as disorder, on a screen where order is doing real work.
Reserve the space even when the icon is absent. A list where some rows have a category glyph and some do not should keep the column, or every label shifts. A shifting left edge is harder to scan than a slightly empty one.
Check it at 1.3×. The em sizing means glyphs grow; the fixed sizes mean the
controls containing them may not. An icon-only control in a row of text is where
those two meet, and it is where the layout first breaks.
Tokens
The three standalone sizes, the em ratio used in text contexts, the icon-to-
label gap and the stroke weights come from tokens/space.json and
tokens/type.json, generated by scripts/build-tokens.mts. Iconography owns no
token family of its own, deliberately: an icon size that is not derived from the
type and space scales is an icon size that does not scale with them.
| Token | What it controls | Used by |
|---|---|---|
| --opsin-space-00rem | 0px. | no component |
| --opsin-space-10.25rem | Gap between an icon and its label. | badge, button, callout, care-card, checkbox, combobox, dialog, dose-tracker, empty-state, field, goal-ring, log-sheet, menu, questionnaire, radio-group, range-bar, result-card, scale-input, scroll-area, segmented-control, select, sheet, slider, source-citation, status-pill, stepper, surface, symptom-picker, tab-bar, tabs, timeline-entry, toast, tooltip, trend-sparkline |
| --opsin-space-20.5rem | Gap between tightly related lines. For the separation between two interactive targets see targets.separation, which is the same number and carries its scope. | alert-banner, avatar, badge, body-map, button, callout, combobox, consent-sheet, dialog, disclaimer-note, dose-tracker, empty-state, field, icon-button, log-sheet, menu, metric-tile, number-field, popover, progress, questionnaire, radio-group, range-bar, range-legend, reading-input, relative-time, scale-input, score-dial, scroll-area, segmented-control, select, sheet, skeleton, slider, spinner, status-pill, surface, symptom-picker, tab-bar, table, tabs, textarea, timeline-entry, toast, tooltip, trend-sparkline, visually-hidden |
| --opsin-space-30.75rem | Inner padding of a compact control. | accordion, alert-banner, button, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, divider, empty-state, field, log-sheet, menu, metric-tile, popover, progress, radio-group, reading-input, result-card, scroll-area, segmented-control, select, sheet, slider, status-pill, stepper, surface, symptom-picker, tab-bar, table, tabs, textarea, timeline-entry, toast, tooltip, value |
| --opsin-space-41rem | The default gap between elements inside a card. | accordion, avatar, badge, button, callout, care-card, consent-sheet, dialog, divider, empty-state, goal-ring, link, log-sheet, menu, popover, range-legend, result-card, scale-input, score-dial, segmented-control, sheet, surface, switch, tabs, timeline-entry, toast |
| --opsin-space-51.25rem | Card inner padding on a phone. | avatar, button, consent-sheet, dialog, questionnaire, sheet, slider, switch, symptom-picker |
| --opsin-space-61.5rem | Card inner padding on a wide screen; gap between cards. | avatar, card, dialog, disclaimer-note, empty-state, field, log-sheet, skeleton, stepper, switch, tab-bar, trend-sparkline |
| --opsin-space-82rem | Gap between sections within a screen. | avatar, body-map, disclaimer-note, popover, range-bar, reading-input, sheet, stepper |
| --opsin-space-102.5rem | Space above a section heading. | avatar, sheet, skeleton, switch |
| --opsin-space-123rem | Gap between major regions of a screen. | avatar, trend-sparkline |
| --opsin-space-164rem | Top of a screen below the safe area; the space a consent sheet leaves above its first question. | dialog, skeleton |
| --opsin-space-205rem | Empty-state vertical rhythm. | no component |
| --opsin-space-246rem | The largest step. Beyond this, use a layout, not a gap. | no component |
| --opsin-space-px0.0625rem | Hairline borders only. | body-map, trend-sparkline |
| --opsin-space-0-50.125rem | Optical nudges. Not a layout step. | badge, dose-tracker, radio-group, range-legend, result-card, scroll-area, segmented-control, status-pill, stepper, switch, tab-bar, tabs, term, trend-sparkline |
| --opsin-target-minimum2.75rem | The opsinjs floor for any interactive control, applied to the hit area rather than to the visible box. | accordion, body-map, button, card, checkbox, combobox, consent-sheet, dialog, disclaimer-note, field, icon-button, menu, metric-tile, number-field, popover, questionnaire, radio-group, reading-input, scale-input, segmented-control, select, sheet, slider, source-citation, switch, symptom-picker, tab-bar, tabs, textarea, toast, tooltip |
| --opsin-target-comfortable3rem | The default for a primary action in the product theme. | dialog |
| --opsin-target-generous3.5rem | A single primary action on a consent, escalation or emergency surface, where a mis-tap has a real cost. | no component |
| --opsin-target-separation0.5rem | The minimum gap between two adjacent targets whose visible boxes are smaller than 44px. It is a floor for undersized targets only. Two targets that both meet the 44px minimum are outside its scope, and separating those is governed by the differing cost of a mis-tap between them rather than by a fixed gap. | alert-banner, card, care-card, consent-sheet, result-card |
| --opsin-gutter-phone16px | The screen gutter in the phone responsive mode. | no component |
| --opsin-gutter-tablet24px | The screen gutter in the tablet responsive mode. | no component |
| --opsin-gutter-wide32px | The screen gutter in the wide responsive mode. | no component |
| --opsin-measure-tight45ch | A caption or a legend. | dialog, empty-state, goal-ring, popover, tooltip |
| --opsin-measure-comfortable66ch | The maximum line length for prose anywhere in the product, including a disclaimer nobody wants to read. | disclaimer-note, result-card, score-dial, sheet, source-citation, term |
| --opsin-measure-wide80ch | Code and machine output only. | no component |
| --opsin-graphic-dial17rem | The maximum width of the ScoreDial arc. This is 16em against the product body of 1.0625rem, which is 17rem and lands on the 4px grid at 68 steps, and it yields to w-full when the column is narrower. | goal-ring, score-dial |
| --opsin-safe-topenv(safe-area-inset-top, 0px) | The top safe-area inset, for installed web apps where browser chrome does not protect the edge. | no component |
| --opsin-safe-rightenv(safe-area-inset-right, 0px) | The right safe-area inset, for installed web apps where browser chrome does not protect the edge. | sheet |
| --opsin-safe-bottomenv(safe-area-inset-bottom, 0px) | The bottom safe-area inset, for installed web apps where browser chrome does not protect the edge. | dialog, sheet |
| --opsin-safe-leftenv(safe-area-inset-left, 0px) | The left safe-area inset, for installed web apps where browser chrome does not protect the edge. | sheet |
Accessibility impact
- Sizing in
emis what makes icons participate in Dynamic Type. A fixed pixel glyph beside scaled text is the most common way an interface looks broken specifically for readers who have set a larger text size. - Icon size is not target size. WCAG 2.2 SC 2.5.8 measures the interactive area; opsinjs sets a higher floor on Density and touch. A small glyph in a large target passes; a large glyph in a small target does not, however easy it is to see.
- Thin strokes at small sizes fail non-text contrast more often than any other mark in the system. A meaningful glyph is measured against its background under SC 1.4.11, at the size and stroke weight it actually renders at.
- Optical alignment is a legibility matter, not a polish one. A glyph that sits visibly low beside a label reads as a separate object, which weakens the association the icon existed to create.
- Reserved space keeps scanning cheap for readers with low vision or reduced attention, who rely on a stable left edge to move down a list.
Related
- Density and touch covers the target floor an icon sits inside, and why the two measurements are separate.
- Dynamic Type says what happens to all of this at the reader's own text size.
- Icon inventory has the roles these sizes apply to.
Health glyphs
The medical symbols opsinjs will not use, because an app that borrows the visual language of clinical authority is claiming something it has not earned.
Imagery
Why an image in a health product is never neutral, the four kinds opsinjs recognises, and why the right number of pictures on a results screen is zero.