opsinjs
FoundationsExpressionIconography

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.

TokenWhat it controlsUsed by
--opsin-space-00rem0px.no component
--opsin-space-10.25remGap 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.5remGap 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.75remInner 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-41remThe 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.25remCard inner padding on a phone.avatar, button, consent-sheet, dialog, questionnaire, sheet, slider, switch, symptom-picker
--opsin-space-61.5remCard 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-82remGap between sections within a screen.avatar, body-map, disclaimer-note, popover, range-bar, reading-input, sheet, stepper
--opsin-space-102.5remSpace above a section heading.avatar, sheet, skeleton, switch
--opsin-space-123remGap between major regions of a screen.avatar, trend-sparkline
--opsin-space-164remTop of a screen below the safe area; the space a consent sheet leaves above its first question.dialog, skeleton
--opsin-space-205remEmpty-state vertical rhythm.no component
--opsin-space-246remThe largest step. Beyond this, use a layout, not a gap.no component
--opsin-space-px0.0625remHairline borders only.body-map, trend-sparkline
--opsin-space-0-50.125remOptical 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.75remThe 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-comfortable3remThe default for a primary action in the product theme.dialog
--opsin-target-generous3.5remA single primary action on a consent, escalation or emergency surface, where a mis-tap has a real cost.no component
--opsin-target-separation0.5remThe 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-phone16pxThe screen gutter in the phone responsive mode.no component
--opsin-gutter-tablet24pxThe screen gutter in the tablet responsive mode.no component
--opsin-gutter-wide32pxThe screen gutter in the wide responsive mode.no component
--opsin-measure-tight45chA caption or a legend.dialog, empty-state, goal-ring, popover, tooltip
--opsin-measure-comfortable66chThe 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-wide80chCode and machine output only.no component
--opsin-graphic-dial17remThe 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 em is 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.
  • 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.

On this page