Skip to content

Create MUI Symbols Library #46183

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 44 commits into
base: master
Choose a base branch
from
Draft

Conversation

dav-is
Copy link
Contributor

@dav-is dav-is commented May 21, 2025

Note

This PR is a draft and still a work in progress. See Todos at the end of the description.

Related issues: #32846 & #42704

Try the new icon picker

Implementation

There can be a benefit to using the icon font, so we can't recommend using SVGs in all cases. To put the decision in the hands of the user, I would recommend creating a unified API surface between the icon font and SVGs. The user would learn a single way of adding icons to their app, and switching between SVGs and the icon font would be as simple as changing the import. If using the icon font, they would gain animations seamlessly. If using SVGs, icons would render instantly on the first load. It's worth noting that the icon font can still "code-split" the icons at build time with the right tooling. I think it would also be fair for applications that have dynamic icon usage (e.g. user-defined category tagging), icon fonts would be a better solution.

Differentiating between "theme" properties and "icon" properties would be the solution here. In my opinion, weight and rounded|outlined|sharp would be theme properties defined at an app level in most cases.

The user would import their icons like this on the most basic level to use our given theme defaults:

import SearchIcon from '@mui/symbols/Search';

A customized theme would look like this:

import SearchIcon from '@mui/symbols-sharp-300/Search';

This could be enforced by a linter rule. An import alias could also be used to have @symbols/Search point to @mui/symbols-sharp-300/Search.

Further customizations (size, fill, grade) of the icon would occur based on context.

import BellIcon from '@mui/symbols/Bell';

export default function NotificationButton({ hasNotifications }) {
  return (
    <IconButton>
      <BellIcon fontSize="large" emphasis="strong" filled={hasNotifications} />
    </IconButton>
  );
}

emphasis="strong" or emphasis=true would translate to grade being set to 200.
emphasis="muted" would translate to grade being set to -25.
emphasis="normal" (default) would translate to grade being set to 0.

filled=true would translate to fill being set to 1.
filled=false would translate to fill being set to 0.
filled=0.5 would translate to fill being set to 0.5 for the icon font and 1 for SVGs. (Alternatively, it might make sense to show unfilled for all values less than 1 when using SVGs)

fontSize=small would translate to a size of 20px
fontSize=medium would translate to a size of 24px
fontSize=large would translate to a size of 40px
fontSize=x-large would translate to a size of 48px

fontSize=16px would translate to a size of 16px and optical-size of 20px (calculated by range)
fontSize=2rem would translate to a size of 32px and optical-size of 40x (calculated by range)
fontSize=3rem would translate to a size of 48px and optical-size of 48x (exact match will be sharpest)

Switching to the icon font could be as simple as changing the import statement (or updating the import alias):

import SearchIcon from '@mui/symbols-font/Search';

This requires a new icon component: VariableIcon. It abstracts away the complexities of SVGIcon to allow the user to choose between the icon font or SVGs. When using Material Symbols, I don't think it makes sense for users to use advanced SVG configurations. We should handle that for them.

To generate these VariableIcon components, we have a helper function createVariableIconFromSvg which wraps the VariableIcon component and passed a SVGIcon component to it (so we still benefit from the optimizations there). We also have the createVariableIconFromFontString function that creates the component using a string for use with the icon font instead of the SVG. The VariableIcon always wraps its child with a <span> element. This is for styling consitancy between the font using <span>icon_name</span and the SVG using <span><svg>[...]</svg></span> and protects the svg element from being directly modified.

When using the icon font, there are also helper components for adding the stylesheet.

import Head from 'next/head'
import MUISymbolsOutlined400GoogleFonts from '@mui/symbols-font/helpers/MUISymbolsOutlined400GoogleFonts'

export default function App() {
  return <Head>
    <MUISymbolsOutlined400GoogleFonts />
  </Head>
}

You can also filter by individual icon:

<MUISymbolsOutlined400GoogleFonts icons={['home', 'search'] />

Each theme has a component (e.g. <MUISymbolsSharp300GoogleFonts />).

For users who value performance over everything, I think we should also offer @mui/symbols-lite which would only bundle filled and unfilled states and only the optical size of 24px. Lite icons would not allow the use of the emphasis prop, in order to encourage the use of the more performant filled state.

For users who want a combination of instant icon loading, but still want icon animations, we could offer @mui/symbols-progressive. This would load the SVG and server render it, but after the font was detected as loaded, it could swap to the icon. This would mirror the behavior of font-display: swap and avoid the shortcomings of the icon font showing the icon text when the font takes too long to load. After the font is loaded, you could animated fill, particularly useful for the main navigation icons. This can still be performant because the icon font can be used with a whitelist of the handful of icons used. Unfortunately, the lite icons couldn't be used with this because with each page navigation, the icons would shift in weight.

Pros

  • Unified API surface between the icon font and SVGs
  • Separate "theme" properties and "context" properties for a more intuitive DX
  • Import only what you need
  • Allows more intelligent runtime handling
  • Simple and intuitive animations when using the icon font
  • Encourages the use of fill and unfilled to convey state
  • Slightly improves understanding of the grade property
  • The user doesn't need to know about the optical size, it is automatically set based on the size of the icon
  • Working autocomplete for icon names
  • More difficult to accidentally mix weights
  • Shorter import paths
  • When using the icon font, allows filling the icon according to progress (e.g. fill={progress}) instead of using a separate icon for each state
  • Alternative package types to suit different use cases
    • @mui/symbols - instant loading, high fidelity, auto codesplitting, higher js footprint
    • @mui/symbols-font - deferred loading, animations, icon selector components / user defined icons, manual codesplitting (icon whitelist), smaller html footprint
    • @mui/symbols-progressive - instant loading, animations, navigation icons, heavier footprint (html, js, and font)
    • @mui/symbols-lite - instant loading, low fidelity / inconsistent, smallest footprint

Cons

  • The user would need to learn a new API surface compared to @mui/icons-material. Given Material Surfaces is not Material Icons v2, I think this is acceptable.
  • A user might want more fine grained control, but I would argue they could always use the SVGs from the Google repo directly with the SVGIcon component.
  • Barrel files may become large, so they are only allowed when using the icon font.
  • The SVG component would bundle 4 optical sizes, 3 grades, and 2 fills (24 SVGs per component). The filled state can be optimized because the beginning of the SVG is identical.
    • SVGs are relatively light, and given we are already code-splitting by the icon. I would argue this is not a big deal and allows us to optimize the experience. These SVG variants also compress extremely well (1/3 the size). If this is unacceptable to the user, they could use the @mui/symbols-lite package.
    • The 4 optical sizes could be used in the import reducing to 6 SVGs per component (e.e.@mui/symbols-sharp-300/SearchSmall), but this would reduce our ability to automatically switch the optical size based on the size of the icon. This might be acceptable given it doesn't easily animate. It would reduce the usefulness of autocomplete. It might be confusing to users why they have a "optical size" and a "font size". [Compromise A]
    • Alternatively, it could be reduced by making "bold" and "fine" import properties to make 8 SVGs per component (e.g. @mui/symbols-sharp-300/SearchBold). This would limit our ability to automatically use -25 grade when in dark mode. [Compromise B]
    • Alternatively, we could split the grade into two components: @mui/symbols-sharp-300/Search and @mui/symbols-sharp-300/SearchBold. This would allow us to use the -25 grade in dark mode. [Compromise C]
    • It's worth noting if these properties are in the import statement, it makes it impossible to animate between them when using the icon font.

Size Benchmarking

I selected an icon (TwoKPlus) to test the impacts on size:

  • A single icon in @mui/icons-material around 470 bytes (440 bytes compressed, 94%)
  • As Proposed: @mui/symbols/Home (optical-size + grade + fill) - 12,400 bytes (4,000 bytes compressed, 32%, 9X larger than MUI Icons)
  • Compromise A: @mui/symbols/Home(Small|Medium|Large|Larger) (grade + fill) - 3,027 bytes (1,092 bytes compressed, 36%, 2.5X larger than MUI Icons)
  • Compromise B: @mui/symbols/Home(Muted|Regular|Strong) (optical-size + fill) - 3,852 bytes (1,528 bytes compressed, 40%, 3.5X larger than MUI Icons)
  • Compromise C: Split grade
    • @mui/symbols/Home (optical-size + grade:normal|muted + fill) - 8,154 bytes (3,144 bytes compressed, 39%, 7X larger than MUI Icons)
    • @mui/symbols/HomeBold (optical-size + grade:strong + fill), - 4,392 bytes (1,732 bytes compressed, 39%, 4X larger than MUI Icons)

It's worth noting that this would be the added weight on the Javascript bundle, not the initial HTML payload. Only a single SVG would be rendered in the HTML. These bundled SVGs benefit a lot from gzip compression which makes sense because each variant is very similar. It's safe to assume any modern application will be using gzip compression for the JS bundles.

It's estimated that a @mui/symbols-lite icon (optical-size:24 + grade:0 + fill, only two variants) could be reduced to ~1,000 bytes (660 byte compressed, 66%, 1.5X larger than MUI Icons)

Icon Selection Docs Page

Because the icons page needs to load all the icon variants, doing so with the package would require bundling 75k modules. When I attempted this, the page would not build, even when using the font icon version. This is not an issue with the packaging, because the typical user's icon selection component would likely only use one theme (e.g. sharp-300) and would only be 3,600 modules.

The docs load the theme and weight that is selected using the Google fonts stylesheet. When selecting a weight it will load the range. For example, the default is outlined-400. If you select outlined-500, it will load the weight range 400-500. If you select 700, it will load 400-700. This is due to a limitation in Google font loading. If you have outlined-400 selected and switch to rounded-400, it will just load rounded with a weight of 400. During this loading, the icons will show a animated skeleton.

The bundle size of this page is greatly reduced because the SVGs are not included. I would argue there isn't much benefit of loading the icons instantly. Repeat visitors of the page should have their icons load instantly because they have the font cached. If SVGs were used, it wouldn't be as easy to cache all the icons as they are baked into the html each time it is loaded.

When opening the icon dialog to see a single symbol, you are able to vary the "context" parameters that are set on the icon component directly: emphasis and fill. The fill component is nicely animated. You can also decide between the font and SVG version of the icon. A new x-large size shows off the 48px version of the icon. You'll notice each size looks roughly similar, where the @mui/icons-material icons seem bolder as they grow.

You notice when the icon is sized up to 200px in the icon dialog, it looks significantly better than @mui/icons-material. This is because the optical size. This is great to showcase for when icons are used in dialogs, like when you want to warn the user with a large caution icon.

There are likely more performance optimizations that could be made for this page. The virtual scrolling optimization was removed in favor of content-visability: auto, but this probably needs more testing. The loading skeleton, might add too many DOM elements to be worth it and could be optimized too.

Considerations

Optical Size

Optical size needs to be set based on the actual icon size so that the icon is consistent with other icons of different sizes. For example, if you used a large icon on a page with many small icons, the large icon would look much bolder than the small icons. Google has a helpful animation that shows this effect here.

Another benefit of the optical size is 20px icons are slightly simplified compared to 24px icons to be sharper on the screen and avoid aliasing. The docs in the material symbols repo says "What is currently not available in Material Symbols? only the 20 and 24 px versions are designed with perfect pixel-grid alignment" This alludes that 40px and 48px icons will eventually be pixel perfect. Justifying us using these sizes are default values for large and x-large icons to increase sharpness. It's worth noting that today with the SVGIcon component, large translates to 35px and x-large doesn't exist. I think we should make this change before it would be breaking for @mui/symbols users.

To quote someone from the Google team, "Also, no stroking change can correctly take you from the 24 px master to the 20 px master or vice versa, when each of them is manually designed to fall precisely on the pixel grid. If you are taking a skeleton and stroking it, and generating as SVGs, either all your 24 px icons will look fuzzy, or all your 20 px icons will look fuzzy. We just spent a big chunk of the last eight months or so revising the 20 px masters to be grid-snapped based on manually tweaked pixel patterns and outlines."
Source

For the sizes that are not exactly the same as the optical size, Google's icon selector automatically switches the optical size based on these ranges:

  • 0-21px: optical size of 20 (small) [-19,+1]
  • 22-31px: optical size of 24 (medium) [-2,+7]
  • 32-43px: optical size of 40 (large) [-8,+3]
  • 44px+: optical size of 48 (larger) [-4,+∞]

If the size is in rem, how do we know what the optical size should be? We could set the optical size based on a base font size of 16px, but if a user increases their font size, they would effectively be increasing the weight of the icons as well. This seems like an ok tradeoff. We could possibly use getComputedStyle to get the font size of the element and calculate the optical size based on that, but that would complicate the possibility of server rendering SVGs. If a user has their font size increased, they would see a flash of a bold icon before the react tree hydrates and rerenders based on getComputedStyle.

If animating the size, when do we switch the optical size? Before or after the animation, or would we recommend using requestAnimationFrame to switch at the threshold Google uses? If animating the size, it might make sense to keep a static optical size to avoid the icon slightly changing during the animation. Given the SVG icon size can't be easily animated because of this, would this make a case for having a separate icon file for each optical size? Then again, including the size in the import makes the user grapple with "what is an optical size?" If the size is in the import, I would recommend only allowing the fontSize to be set within the range shown above. This would enable small growth or shrinking animations for emphasis, but not a full size change.

In the end, I settled on a CSS only approach which switches the SVG based on a @container query. Which allows us to inherit the font size using CSS as the user would expect. It also supports rem units nicely, which is useful when the font size is increaded (or decreased), the correct optical size would be used. Also, since it uses CSS, there isn't a flash of bolded icon before the JS hydrates the page.

A benefit of the icon font is that the optical size is set automatically.

Weight

Weights 100-700 are available. We might want to expose them as their names to be more familiar and descriptive:

  • 100: thin
  • 200: ultralight
  • 300: light
  • 400: normal
  • 500: medium
  • 600: semibold
  • 700: bold

In the Material 3 spec, weight 100 is not allowed to be used with icons that are smaller than 24px. Source

The spec also forbids mixing weights within a set of icons, so it wouldn't make sense to use weight as a indicator of which icon is selected. Source

It also seems using stroke-weight instead of individual SVGs is not feasible. Source

Grade

We have -25, 0, 200 for grades. It gives even more variance on weight. It seems the intended use of this would be to take a theme weight of 400, and allow "muted", "normal", and "strong" variants of the icon. This makes me think weight is a theme property, and grade is an icon property based on the context of the icon.

In the Material Symbol docs, it mentions, "To highlight a symbol, increase the positive grade." Source

It's worth noting that the Material 3 spec recommends using -25 grade when in dark mode to reduce glare. This would justify bundling grade in a single component as it is dependant on context. Source

Fill

fill is a variable that was not mentioned in #42704. If following the recommendation of that issue, it seems we would export /variants/sharp/100/neg25/filled (with combined optical size of 20, 24, 40, 48 in one file).

In the Material 3 spec, fill is mentioned as an indicator to show it has been selected (Source). In the icon font, the switching between fill and unfilled has a deliberate animation. The employee from Google mentioned that these animations cannot be achieved with SVGs. Would that make a case for having filled and unfilled icons in the same file? Having no animation support for icons means there is a serious benefit to using the icon font.

Mentioned in the Material 3 spec, it says, "A fill attribute can be used to convey a state of transition, such as unfilled and filled states. Values range from 0 to 1, with 1 being completely filled." That seems to allude that you could set fill to 0.5 and it would have a half-filled icon. This further justifies that fill would be an icon property, not a theme property.

Editor Autocomplete

If I type <Home /> into my editor, it would be nice to autocomplete the import according to the icon theme I am using. We could achieve this by installing only one theme at a time. Instead of installing @mui/symbols, we could ask the users to install @mui/symbols-rounded-300 or @mui/symbols-rounded. If two weights were desired, the user would only see two options for Home instead of all the theme variants.

I think it also make sense to call the module @mui/symbols instead of @mui/symbols-material so that it is shorter. It seems we intend on adding our own icons and removing some that don't work well, so it would make sense to deviate a little in the name as well.

Module Size

Someone from the Google team mentioned that all the possible variants add up to more than a million SVG files. Source

By my count, there are 468 SVG files per icon with 156 files for each style (Rounded|Outlined|Sharp). With 3618 icons, that's 1,693,224 SVG files.

This might not scale very well and could drastically increase the install time of the module. We should consider splitting into multiple packages like mentioned above, or reducing the number of files by combining the parameters that depend on the context of the icon.

As proposed, this would result in 75,978 Javascript files built. If the package is split by rounded|outlined|sharp (e.g. @mui/symbols-sharp), that would be 25,326 files. Since the proposal also splits by weight (e.g. @mui/symbols-sharp-300), each module only has as many files as icons (3618). This is a lot more manageable than 1,693,224 files. Additional weights can be installed as needed making it modular.

It's estimated that the size of @mui/symbols-sharp-300 would be around 40MB uncompressed with ESM only. The compressed size should be closer to 13MB. This is significantly larger than @mui/icons-material which seems to be a little larger than 4MB. It also has about 1500 additional icons though.

Checking Files into Git

Are we able to check 1.6 million SVG files into git? It seems for @mui/icons-material there are also CommonJS and ESM versions for each SVG file. If we use the same pattern for Material Symbols, that's 5,079,672 files checked into git. Is this for monorepo reasons?

It seems clear that we need to combine some of these SVG files into a bundled file, see recommendations below. I also think git should only contain one version of either the SVG or JS file, instead of duplicating three times. The build output itself should be stored on NPM only.

To demonstrate the scale of the build output, I created a sub-PR with only the @mui/symbols-font packages built: #46194 I think the SVG version was just to big to include.

I understand that each version of the icons may need to be available through git, so maybe a solution here could be to use the Google repo as a submodule.

I think the best solution would be to create a new repo just for generating these JS files that contain SVGs. All the SVGs consume 1.5 GB. That's not realistic to store within the monorepo. For LTS reasons we will need an archive of previous icon releases. Google's API doesn't have a versioned endpoint, so we can only fetch the latest SVGs. We can release new versions of the symbols within that repo instead of weighing down the main repo. In the separate repo we would publish @mui/symbols-svg-sharp-300 version 0.1.0. Within the monorepo, we would publish @mui/symbols-sharp-300 version 7.1.0 in sync with the current MUI version. We should also archive the icon fonts in this repo and package them to avoid Google removing icons in the future and breaking our packages.

TODO

  • Create mui-symbols package
  • Create script that downloads Material Symbols from Google Fonts
  • Create script that combines the SVG variants into a single file (@mui/symbols/Search)
  • Create VariableIcon component
  • Create createVariableIconFromSvg function
  • Create the /material-ui/api/variable-icon/ api page
  • Create the /material-ui/material-symbols/ docs page
  • Create createVariableIconFromFontString function
  • Create script that builds the @mui/symbols-font version of the icons
  • Evaluate git storage of SVGs
  • Figure out what files must be checked into git
  • Create barrel file at @mui/symbols-font (not for SVG-based due to bundle concerns)
  • Allow built packages to be consumed by the monorepo
  • Get the symbol selector on /material-ui/material-symbols/ working
  • Experiment with font-size: inherit and if it should be allowed (yes it should)
  • Experiment with if helper classes for fontSize (e.g. .MuiVariableIcon-fontSizeSmall) are dangerous (not with @container)
  • Select optical size with @container queries to avoid JS and support rem units
  • Create the /material-ui/icons/#variableicon docs page
  • Update /material-ui/icons/#font-vs-svgs-which-approach-to-use to detail VariableIcon
  • Add a script that generates the types for each symbol
  • Add tests for the VariableIcon component
  • Add tests for the build script
  • Complete bundle size analysis and decide on stable API surface
  • Create separate repo for SVGs and JS build
  • Consider swapping weights 100, 200, 300, etc with thin, ultralight, light, etc
  • Get publishing working
  • Consider if CommonJS version should be published at @mui/symbols-cjs (they can't both be included because of size concerns)
  • Experiment with prohibiting weight 100 + size less than 24px as the spec says
  • Experiment with @mui/symbols-progressive
  • Experiment with @mui/symbols-lite

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant