unicode-range on Material Symbols icon_names @font-face misaligns ligature icons
- Dominant language
- HTML
- Stars
- 20.5k
- Forks
- 2.9k
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 95
Description
### Describe the issue
When `https://fonts.googleapis.com/css2?...&icon_names=...` is requested for a **large** Material Symbols icon set, the **woff2** `@font-face` now includes a `unicode-range` descriptor that covers only the ligature *input* characters (`a-z`, `_`, digits) and **omits the icons' own PUA codepoints**. This descriptor:
1. Causes ligature-rendered icons to sit **~3.5px too low** (at 24px) in Chromium — clipping in any fixed-height container — and
2. Makes **codepoint-addressed** icons (`content: "\f7ff"`) fail to render at all, since their codepoints fall outside the declared range.
Removing the `unicode-range` descriptor fixes both. The font file itself is correct; this is purely the served descriptor.
### Versions involved
- Material Symbols Rounded internal Google Fonts version: `v2.944` → `v2.947`
- Bug observable starting on/around 2026-05-29 → 2026-06-02 (matches the public [Material Symbols Changelog](https://material-symbols-changelog.vercel.app/) update window).
- The bug appears to be in large, **subsetted** font responses.
### What the API returns
Request (axes + a large, alphabetically-sorted `icon_names` list, modern-Chrome UA → woff2):
```
https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0&icon_names=<~500 sorted icon names>&display=block
```
Response (abridged):
```css
@font-face {
font-family: 'Material Symbols Rounded';
font-style: normal;
font-weight: 400;
font-display: block;
src: url(https://fonts.gstatic.com/l/font?kit=…&v=v346) format('woff2');
unicode-range: U+32-35, U+5f, U+61-7a; /* digits 2–5, "_", a–z = the ligature-name chars */
}
```
Notes on when it appears:
- **woff2 only.** A non-woff2 UA gets a `truetype` response with **no** `unicode-range`.
- **Large sets only.** A 1–9 icon request returns no `unicode-range`; a ~500-icon set returns the range above. So existing apps regress silently as their icon list grows.
- A few-months-old build of the identical request returned the `@font-face` with **no** `unicode-range`.
### Expected
The `@font-face` for a ligature icon font should either omit `unicode-range`, or declare a range that includes the **icons' PUA codepoints** (the glyphs the font actually provides), not just the ligature-input letters.
### Actual
The range covers only `a-z _ 2-5`. The icons live in the PUA (e.g. `interactive_space` = `U+F7FF`), which is outside the range, producing the two failures above.
The `css2` API, for **large** Material Symbols `&icon_names=` subsets, returns a **woff2** `@font-face` that includes a `unicode-range` descriptor covering only the ligature *input* characters (`a-z`, `_`, digits) and **omitting the icons' own Private Use Area (PUA) codepoints**. This single descriptor causes two regressions:
1. **Ligature-rendered icons shift ~3.5px too low** (at `font-size: 24px`) in Chromium, clipping in any fixed-height icon container — i.e. the very common 24px icon box used by Material / MDC icon components.
2. **Codepoint-addressed icons** (`content: "\f7ff"`) **fail to render entirely**, in any browser, because their codepoints fall outside the declared range.
The font file itself is correct; **removing the `unicode-range` descriptor fixes both**.
Filed here rather than in `google/material-design-icons` because the defect appears to be in the **`css2` serving output**, not in the font file.
**To Reproduce**
**A. Minimal, self-contained** (does not depend on Google's current output — toggles only `unicode-range` on identical bytes):
```html
/* Same woff2 bytes for both; the ONLY difference is the unicode-range line. */
@font-face { font-family: NoRange;
src: url(MaterialSymbolsRounded.woff2) format('woff2'); }
@font-face { font-family: WithRange;
src: url(MaterialSymbolsRounded.woff2) format('woff2');
unicode-range: U+32-35, U+5f, U+61-7a; }
.icon { font-size: 24px; line-height: 1; display: inline-block; }
.clip { width: 24px; height: 24px; overflow: hidden; outline: 1px solid red; }
```
The `WithRange` icon renders ~3.5px lower and is clipped at the top; `NoRange` is centered.
**B. Directly from the `css2` API.** The descriptor is emitted only for the **woff2** variant (modern-browser User-Agent) and only for **large** subsets. In our testing it is **absent at ≤460 icons** and **present at ≥465 icons**. A request such as:
```
https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0&display=block&icon_names=<≥465 alphabetically-sorted names>
```
returns (woff2 UA):
```css
@font-face {
font-family: 'Material Symbols Rounded';
font-style: normal;
font-weight: 400;
font-display: block;
src: url(https://fonts.gstatic.com/l/font?kit=…&v=v346) format('woff2');
unicode-range: U+32-35, U+5f, U+61-7a; /* digits 2–5, "_", a–z = the ligature-name chars */
}
```
This is why production apps hit it (they routinely request hundreds of icons) while small test cases do not. Note: `icon_names` must be sorted alphabetically or the API returns `400 Invalid selector`.
Minimal reproducing `icon_names` list (sorted; ~465 names is the smallest we found that flips the descriptor on — absent at 460, present at 465):
Minimal reproducing icon_names list (465 sorted Material Symbols names)
```
accessibility,accessible,account_tree,acute,ad,add,add_circle,adjust,admin_panel_settings,air,amend,analytics,anchor,animation,announcement,api,architecture,archive,arrow_back,arrow_circle_right,arrow_downward,arrow_drop_down,arrow_forward,arrow_right,arrow_upward,article,assessment,assignment,assignment_turned_in,attachment,attribution,auto_awesome,auto_fix_normal,auto_stories,autoplay,autorenew,backspace,badge,bar_chart,block,bolt,book,bookmark,bookmark_add,box,broken_image,browse,brush,bubble,bubbles,bug_report,build,business,cached,cadence,calculate,calendar_today,call,camera,cancel,capture,cards,cases,cast,category,celebration,chat_bubble_outline,check,check_box,check_circle,check_small,checklist,chevron_left,chevron_right,chips,circle,circles,class,cleaning,clear,close,cloud,cloud_done,cloud_off,cloud_upload,co_present,code,collections,colors,commit,compare,conditions,construction,contacts,content_copy,content_paste_search,contract,contrast,conversation,conversion_path,create,css,cut,cycle,dashboard,dashboard_customize,data_object,database,delete,delete_outline,description,deselect,desk,details,detector,devices,diagonal_line,dialogs,diamond,difference,distance,dns,do_not_disturb_on,docs,domain,done,door_front,download,draft,drafts,drag_indicator,draw,dropdown,edit,edit_calendar,edit_note,email,emoji_events,emoji_objects,enable,ent,equal,error,error_outline,event,event_available,event_list,expand,expand_less,expand_more,experiment,explicit,explore,extension,eyebrow,face,fact_check,factory,falling,favorite,feed,feedback,fence,files,filter,filter_list,fingerprint,fit_screen,fitness_center,flag,flight,flip,floor,flowchart,folder,folder_off,footprint,format_quote,forum,forward,fullscreen,function,functions,gate,gesture,gif,glyphs,grade,gradient,grading,grain,graphic_eq,grid_on,grid_view,group,group_add,group_off,groups,hardware,hd,hearing,height,help,help_outline,hexagon,hide,highlight,history,history_edu,home,host,hourglass_disabled,hourglass_empty,hourglass_top,html,http,https,iframe,image,image_not_supported,inbox,info,ink_eraser,ink_highlighter,input,interactive_space,inventory_2,ios_share,iso,join,keep,key,keyboard,label,label_off,lan,landscape,language,launch,layers,light,lightbulb,lightbulb_2,link,list,lists,lock,lock_open,lock_open_right,lock_reset,login,logout,looks,loop,lowercase,mail,mail_outline,manage_accounts,map,margin,mark_email_read,masks,matter,memory,menu,menu_book,merge,message,mic,mic_none,mic_off,mobile,mode,more,more_horiz,more_vert,mouse,move,movie,navigation,news,next_plan,not_started,note,notes,notifications,numbers,opacity,open_in_new,orders,outlet,output,overview,pace,package,padding,pages,pageview,paid,palette,pan_tool,password,pattern,pause,pause_circle,pen_size_3,pen_size_4,pen_size_5,pending,pentagon,people,percent,person,person_add,person_off,person_outline,person_raised_hand,person_remove,phone,photo,photo_camera,pill,pin,pinch,place,play_arrow,play_circle,playlist_remove,plumbing,policy,poll,pool,post,power,preview,priority,priority_high,privacy,problem,procedure,progress_activity,psychology,public,push_pin,query_stats,queue,quiz,radio,radio_button_unchecked,rate_review,receipt,recommend,rectangle,redo,refresh,reminder,remove,remove_circle_outline,reorder,repeat,replay,reply,report,resize,restart_alt,restore,resume,reviews,rocket_launch,room,rotate_auto,route,router,rubric,rule,save,scale,scan,scanner,scene,schedule,schema,school,science,score,script,sd,search,search_insights,search_off,segment,select,send,sentiment_dissatisfied,settings,shadow,shapes,share,shield_lock,shield_person,shift,shuffle,signature,skeleton,skip_next,sort,source,speed,spellcheck,square,stack,stacks,stairs,star,star_border,start,step,steps,sticky_note_2,stop,storage,store,straight,strategy,stream,style,stylus,stylus_note,subheader,subject,subscript,subscriptions,summarize,support,support_agent,swap_horiz,switch,switches,sync,tab,table,tablet,tabs,tag,target,task,terminal,text_fields,texture,thumb_up,timelapse,timeline,timer,title,titlecase,toast,today,toggle_off,toggle_on,token,toolbar,tooltip,topic,touch_app,tour
```
**Expected behavior**
For a ligature icon-font subset, the `@font-face` should either omit `unicode-range`, or declare a range that **includes the contained icons' PUA codepoints** (the glyphs the font actually provides), so the declared range matches the font's real coverage. Either approach fixes both regressions.
**Additional context**
*Quantified shift.* `getBBox()` of `interactive_space` at `font-size: 24px`, relative to the text baseline:
| `@font-face` | ink top | ink bottom | ink **center** | height |
| ---------------------------------- | ------- | ---------- | -------------- | ------ |
| no `unicode-range` | −26.5 | +2.5 | **−12.0** | 29 |
| `unicode-range: U+32-35,U+5f,U+61-7a` | −23.0 | +6.0 | **−8.5** | 29 |
A **+3.5px** downward shift, identical on v2.944 and v2.947. Font internals: UPM 960; `hhea` / `OS/2` ascent 1056, descent −96 (USE_TYPO_METRICS set); the `interactive_space` outline spans y = [40, 920] — centered in the em. The glyph is unchanged; only the line-box baseline placement changes.
*Possible mechanism.* `unicode-range` assumes the typed codepoint is the rendered codepoint. For a ligature icon font that doesn't work: you type `a-z_` (in range) but a PUA glyph renders (out of range). With the range present, Chromium appears to treat the face as a per-character fallback rather than the element's primary font, computing the line baseline from fallback-font metrics; with no range, the icon font's own metrics apply and the glyph centers.
*Conditions (why it is easy to miss).*
- **woff2 only** — a non-woff2 UA gets a `truetype` `@font-face` with **no** `unicode-range`.
- **Large subsets only** — absent at ≤460 icons, present at ≥465 in our tests.
- **Build-date dependent** — an older build of the identical request returned no `unicode-range`; the change appeared with no client-side change.
*Suggested fix.* For ligature icon-font subsets, omit `unicode-range` from the `@font-face`, or extend it to include the contained icons' PUA codepoints.
*Environment.* Chrome / Chromium stable, macOS (devicePixelRatio 2). Material Symbols Rounded via `css2`, woff2 response, `v=v346`. (Firefox / Safari not yet confirmed.)
*Related issues.*
- google/fonts#282 — `unicode-range` causes a rendering anomaly that disappears when removed.
- google/fonts#3097 — served `unicode-range` does not match the font's actual glyph coverage.
- google/fonts#1285 — subset ranges exclude needed characters.
- google/fonts#9721 — `icon_names` subset serving issues.
- google/material-design-icons#1500 — Material Symbols variable-font vertical metrics.
- google/material-design-icons#1997 — icon_names subset produces glyphs with wrong stroke/fill state
**Screenshots**
_Before_
_After_
Contributor guide
Assessment
This issue has not been assessed yet.