Repository navigation
Material Plus 1.10.0
This release is about what the library costs the site it runs on. A server-rendered page hydrates without the mismatches that made React throw its markup away and render it again, and its header, panes, charts, mockups and priority image are drawn from the first paint where they stay. Tables, trees, filtered lists and charts re-render only what a change touches, a page of 1,430 elements styles in 21ms in WebKit where it took 164ms, and a handful of opt-in props keep links, headings and pictures in the markup a crawler reads.
Added
-
MPLocaleProvidertakesserverLocale, for a server-rendered page that cannot tell its reader's language on the server. A date or a number formatted with no locale is written in the runtime's own, and a server and a browser are two runtimes: the markup said1,234.5, the first client render said1.234,5, and React discarded the server's markup and rendered the page again.serverLocaleis the language the server pass and the hydrating pass are both written in; the dates and numbers are rewritten in the reader's language once the page has hydrated. A server that formats with neither alocalenor aserverLocalenow says so in its log, once. See On a server. -
MPNavigationMenutakeskeepMounted, for the links a crawler should find. A closed panel is not in the page at all, so links that exist only inside one were in neither the HTML a server sent nor the document a crawler rendered. WithkeepMountedevery panel's links are in the server's markup, hidden, and stay in the document after a panel has opened and closed. -
MPDrawerandMPSidebartakekeepMounted. A closed modal drawer is not in the page, so belowcollapseBelowa sidebar's links left the document a crawler renders at a phone's width. WithkeepMounteda closed drawer's contents stay in the document, hidden. -
MPAvatartakeskeepMounted, for a long list of them. By default a picture is fetched by a script after the page has hydrated, so it is in no server's markup, the preload scanner never sees it andloading="lazy"is ignored — fifty avatars download together the moment the page hydrates, competing with its largest picture. WithkeepMountedeach is an<img>from the start, loaded by the browser on its own terms, with the initials under it until it has arrived. It needs@base-ui/react1.8 or later. -
A router's
Linkcan draw the links inMPBreadcrumbItem,MPNavigationMenuItem,MPNavigationMenuLink,MPMenuItemandMPTreeItem, throughrender, andMPPagination's, throughrenderLink. Each drew a plain<a>, and under App Router a plain<a>to a page of the same site is a full page load, with no client-side navigation and no prefetch.render={<Link />}keeps thehrefon the component, written once, and lets the router draw the element — the arrangementMPListItemalready had. -
MPAccordiontakesheadingLevel. Each section's title is a heading, and it was always an<h3>— so an accordion under an<h3>of its own, or directly under the page's<h1>, broke the outline a screen reader navigates by and a search engine reads a page's structure from. The default is still3. -
MPAnimateHeadline,MPAnimateTyping,MPAnimateScrambleandMPAnimateSplittakerender, asMPAnimateFadealready did. Their root is a<div>, and the natural place for a rotating word or a typed phrase is inside a paragraph — where an HTML parser closes the<p>at the<div>, so the server's markup and React's tree disagree and hydration fails.render={<span />}makes the root inline. -
MPConfigProvidertakesreserveSupportingText. A field opens a line under itself when it has a description or an error, and everything below moves down. An error that arrives well after the reader typed — a server's answer, a debounced check,MPForm'serrors— is a layout shift with nothing the reader did to explain it. WithreserveSupportingTextevery field in the subtree keeps that line from the start, and a message fills it in place. -
--mp-scrim-blursets how much the scrim under a dialog, a drawer or the command palette blurs the page. The 2px blur re-blurs the whole window on every frame the scrim fades and every frame anything under it changes, which is a cost a low-end phone feels.0pxkeeps the tint and drops the filter; the default is still2px.
Changed
-
The stylesheet costs the page around it far less to style. The colour roles are declared on every element so that a scoped
--mp-source-coloror.darkcan reach them, and that rule also named::beforeand::after— which made a browser resolve both pseudo-elements of every element on the page, this library's or not, to find out whether either drew anything. Measured on a plain page of 1,430 elements, the first style pass took 164ms in WebKit and now takes 21ms, and 68ms in Firefox and now 26ms. A pseudo-element inherits the roles its element resolved, so every colour is drawn exactly as before, scoped themes included. -
A media query is asked once per page, not once per render. The reduced-motion, colour-scheme and collapse stores built a fresh
MediaQueryListevery time React read them — on every render of every component subscribed, and again to check nothing had changed — so twelve animated lines built seventy-two lists on mount. Each query now has one list, shared.MPShortcutlikewise reads the platform once rather than rebuilding the user agent string on every render. -
Everything that watches whether it is on screen shares one observer. Every
MPAnimate*withtrigger="visible", everyuseMPOnScreenand every autoplayingMPCarouselcreated anIntersectionObserverof its own, and the browser checks each one on every frame the page scrolls — a page of forty fading cards was forty observers asking one question. Elements that ask the same question, at the same threshold and margin, are now watched by one, and each is told about itself exactly as before. -
An endless animation holds while it is off screen.
MPAnimateLighting's glow repainted its gradient every frame and an indeterminateMPProgressLinearmoved its bars by layout every frame, for as long as the page was open and wherever on it they were. Both, and an indeterminateMPProgressCircular, now pause while scrolled out of view and carry on from where they stopped when they come back. -
An
MPImagegivenpriorityis drawn the moment it arrives, rather than faded in. The fade waits for the picture'sloadevent, and on a server-rendered page that event is only heard once the page's JavaScript has hydrated — so the one picture a page is judged by sat transparent over bytes the browser already had, and a transparent picture does not count as its Largest Contentful Paint. With the script held back 1.5 seconds it painted at 1.9 seconds, where a bare<img>painted at 0.3. Its placeholder is now drawn under it instead of over it. A picture withoutpriorityfades in as before. -
A fixed
MPHeaderin anMPPageLayouthas its room from the first paint. The layout pads the page by the bar's measured height, and measuring is an effect — so a server-rendered page drew its content under the bar and pushed it down by the bar's height once it had hydrated. The stylesheet now pads by the bar's size from the start, and the measurement corrects only a bar its content made taller. It needs:has(); an older browser behaves as before.MPHeadercarriesdata-mp-positionfor it. -
MPPanespaints at itsdefaultSizefrom the first frame. Until the split had measured itself every pane took an even share, so a server-rendered page drew a 50/50 split and then moved every boundary to25%or240pxonce it hydrated — a layout shift across the whole of an app shell, measured at 0.038 for a two-pane split. AdefaultSizeis now written into the pane's CSS until the measurement arrives, and the measurement lands where the CSS already was. -
MPGaugeChart's reading and the content in anMPPieChartring start where they stay. Both are placed from the size of the chart's box, which a server does not know, so a server-rendered page drew the gauge's value against the top edge and a ring'scenterin a zero-wide box at the left, and moved both into place once the page had hydrated. Until the box is measured each is now placed as if the dial or the ring fills the box's height, which is exactly where it lands in any box at least as wide as it is tall. -
An
MPMockupgiven awidthor aheightin pixels is in the first paint. The device is hidden until it has a scale, and the scale was always measured — so on a server-rendered page a hero mockup, screenshot and all, stayed hidden until the page had hydrated. A size in pixels settles the scale by arithmetic, so the device is drawn from the server's markup on. A mockup that fills a fluid width is still measured, now before its first paint when it mounts in the browser. -
A chart draws its picture in the frame it mounts in. It measured its box in an effect, which runs after the browser has painted, so a chart mounted in the browser showed an empty box for a frame first — and a server-rendered one stayed empty until the frame after it hydrated. It measures before the paint now.
-
A chart under a moving pointer renders only when what is under the pointer changes. A scatter and a timeline stored the pointer's position on every move although neither ever reads it, a heatmap stored a fresh cell for every pixel inside one cell, and a zoom drag stored the selection again for every pixel inside one category — and each of those is a render of the whole chart. Nineteen moves over one point cost eighteen renders and now cost at most one. The table behind the picture and the legend no longer render at all on a hover, which was half of what one cost on a line chart, and
valueLabels="extremes"finds a series' extremes once rather than once per point. -
A chart writes less and works out less. A long category axis drew an empty group for every category it neither ruled nor labelled — three hundred and sixty-five on a year of days — and every number in the table behind the picture wrote its formatting options out as a cache key, once per cell per hover. Neither happens now; what is drawn and announced is the same.
-
MPDataTablere-renders the rows a change touches, and no others. Every tick, sort, page turn and search keystroke rendered every row of the page with its cells and its checkbox; ticking one of forty rows rendered forty. Rows are memoised now, so a tick renders the row it ticked. Dragging a column wider commits one width a frame rather than one per pointer event —onColumnWidthsChangehears fewer intermediate widths and the same final one — a table nobody is searching no longer folds every cell for search as it hydrates, and a sort reads each row's value once rather than twice per comparison. -
MPTreeViewredraws the rows a change is about, and no others. Which rows are open, which are chosen and which holds the tab stop travelled together in the tree's context, so every arrow key, every click and every branch opened re-rendered every row in the tree. Each row now reads only its own three flags, and moving through a tree of a thousand rows redraws two of them per key. -
MPTreeSelectkeeps up with typing in a large tree. Every keystroke folded every node's label again before comparing it, and a one-letter query, which opens most branches of a big tree, held the keystroke until the whole tree had been drawn. Each node is folded once now, and the tree catches up with the query in the time left over after the letter is drawn in the field. -
A filtered list keeps the rows the filter left.
MPTransfer,MPComboboxandMPCommandPalettekeyed their rows by position and value, so a filter that hid one row changed the key of every row after it, and typing a letter into a long list unmounted and remounted all of them — losing whatever state each held. Rows are keyed by value and by which occurrence of it they are, which still tells a repeated value apart and no longer moves when the rows before it go. -
MPCodeBlockcolours a block when it comes near the screen, one block per task. Every block in a language waited on the same grammar and resumed together when it arrived, so a page of twenty blocks was coloured in a single long task — the nineteen nobody had scrolled to included — with the reader's input queued behind it. Each block is now coloured as it nears the viewport, in a task of its own, and drawing the coloured lines yields to a key press that arrives in the middle. A block is plain until then, as it always was on its first frame. -
MPAnimateCounterstops reading frames once there is nothing to read. It follows its animation by reading the counted value back every frame, and stopped only when the animation finished — so a counter waiting fortrigger="visible",manualorhover, or held bypaused, read the same number sixty times a second for as long as the page was open, and atimeline="view"counter kept reading while scrolled out of sight. It now rests while it is held and while a scroll-driven one is off screen. -
An
MPAnimate*entrance starts without a forced layout the first time. Starting an animation rewound it first, and a rewind is a forced layout — which the first start never needed, since an animation that has not run is already on its first frame. A row of cards scrolled into view together paid one forced layout each, in the frame they appeared in. Only a replay rewinds now.MPAnimateMarqueealso stopped measuring itself again on every render of its parent. -
MPAnimateSplitwrites a fraction of the markup it did. Every piece carried all seven of the entrance's slots inline, so a headline of eighty characters splitby="character"was 27 kB of HTML. The slots the pieces share are written once on the set and reach each piece by inheritance; a piece keeps only its delay, and its duration whendurationStepstretches it. The pieces animate exactly as before. -
MPSnackbarProviderputs one live region on the page instead of six. It mounted a Base UI viewport for each of the six positions, so a page carried six regions named "Notifications", re-rendered all six on every message, held six sets of window listeners while one was showing, read a high-priority message out six times, and paused on hover for whichever stack had registered last. One viewport now covers the window, lets every pointer through, and holds a stack for each corner that has a message in it; the region is still there from the first render. -
An open
MPTourmoves its spotlight without re-rendering itself. It follows its target by measuring it every frame, and every frame the target moved — a smooth scroll to it is one per frame — was a render of the whole tour, card included, to shift one rectangle. Moves after the first are written straight onto the spotlight, which halves the work of following a moving target. -
MPTabsmeasures its bar when the bar changes, not on every render. It read the bar's scroll width after every render to know whether to fade an end, which forced a layout on each tab change and each render of anything above it, and it read the bar's direction on every scroll event. Observers now say when the box, a tab's size or the set of tabs changes, and scrolling measures once a frame. -
MPDateRangePickerstops rebuilding its second month while the pointer previews a range. The right-hand month was a new date on every render, and its grid keys forty-two dates and their spoken names on it — so every cell the pointer crossed while a range was half chosen formatted all of them again. Two cells crossed cost ninety dates written; now they cost a handful. -
MPConfirmProviderno longer re-renders everyuseMPConfirmcaller when its parent renders.defaultsis usually written inline, so it was a new object each time, and the context value was rebuilt with it. The defaults are now read when a question is raised, which is also when a changeddefaultstakes effect.
Documentation
-
The getting-started guide registers languages in a client module under App Router. It said
registerMPMessagescould be called from a server file, but a server component and the client components it renders are separate module instances of the library there — a table registered from a server file reached no component that speaks, and every one of them went on speaking English. The guide now registers it in a'use client'module the root layout renders. -
MPScrollZone's page says which buttons setting a server-rendered page wants. Withbuttons="auto"the strip cannot know it overflows until the page has hydrated, so two inline buttons appear afterwards and move the strip;alwaysorbuttonPlacement="overlay"move nothing. -
MPCombobox's page says what to do with a long list. Every row it shows is a real element, so a few thousand make the popup pause as it opens. Thelimitsection shows the cap, which applies after the filter, and points a list too long to send at all tofilter={null}. -
MPAppLogo's page says how to keep abaremark from moving the header. The mark takes its width from the file and is drawn zero pixels wide until the file arrives. Passing the file'swidthandheightinimagePropsreserves the width from their ratio. -
The size figures in the README and the guide are this build's again. Every export is 137.5 kB gzipped and the whole stylesheet 174.3 kB, 23.7 kB compressed. The guide said 130.6 kB for every export, which had already grown to 133.7 kB before this release; the rest moved with the fixes above.
Fixed
-
useMPWindowClasshydrates a server-rendered page without a mismatch. It handed React its window measurement as the server snapshot, and React reads the server snapshot again in the browser while it hydrates — so on every window outside the classonServernamed, which with the defaultexpandedis every phone and every desktop of 1200px or wider, the first client render disagreed with the server's markup and React threw the page away and rendered it again. The hook now answersonServerduring hydration and the real class right after, which is what its documentation always said it did. -
MPCalendarmarks the reader's today, not the server's. A calendar rendered on a server marked the server's today, which for some hours around every midnight is a different day from the reader's — and React does not repair an attribute while it hydrates, so the ring andaria-currentstayed on the wrong day until the grid re-rendered. Server-rendered markup now marks no day, and the browser marks its own today once the page has hydrated. A calendar mounted in the browser marks today from its first render, as before. -
A field's label names its control in the markup a server sends.
MPTextField,MPNumberField,MPCheckbox,MPSwitchandMPRadiopointed their<label for>at the field'sid, while Base UI draws the control with a generated id until the page has hydrated — so in a server's markup every label named an element that did not exist, and pressing a label or reading the form with a screen reader before the script had run reached nothing. The label now follows whatever id the control is drawn with, before hydration and after. -
MPAnimateCounterwrites its number in the provider's language. It formatted in the runtime's locale unless given alocaleof its own, so it ignoredMPLocaleProvider— and on a server-rendered page, wrote the server's language into the markup and the reader's into the first client render. -
A progress bar, a meter, a slider and a number field write their numbers in the provider's language. All four hand the value to Base UI to format, and Base UI formats in the runtime's locale unless it is told one, so under
<MPLocaleProvider locale="de">they went on writing1,234.5while everything around them wrote1.234,5— and on a server-rendered page, wrote it in the server's language.MPGaugeChartwas told nothing either, despite alocaleprop of its own.MPProgressLinear,MPProgressCircular,MPProgressBox,MPMeterandMPSlideralso takelocalenow, like every other component that writes a number. -
A locale tag that is not quite a tag no longer takes the render down. The words in this library's table matched
ko_KRandko-KR,ko;q=0.9— a request'sAccept-Languagepassed straight through — on their language, and thenIntlthrew aRangeErrorat the first date or number. A tag is now tidied beforeIntlsees it: the first of a list, without its weight, with hyphens; and one that still does not parse formats in the runtime's own locale. The cache of resolved words is also capped, as theIntlcaches already were, so a server handed every request's language does not keep them all. -
mpColorSchemeScriptcan be called from a server component. It lived in the same module asuseMPColorScheme, and a module that uses a hook is a client module — so calling it in a Next.js root layout, which is where the documentation said to put it, failed the render, and the only way to avoid a light first paint for a reader who chose dark was not available on the framework the library is most often server-rendered with. It is a module of its own now, importing nothing that renders. Import it from where you always did. -
useMPColorSchemedraws a remembered choice without the<head>script. It read the choice from storage and reported it, but onlysetSchemeever wrote the attribute that draws it — so a page without the script stayed light for the whole visit while the hook saiddark. It is drawn once the page has hydrated now; the script is still what draws it before the first paint. -
An
MPPillrendered open on a server arrives open. Its details were sent at no height and grew into place once the browser had measured them, pushing down everything under the pill. An open panel that has not been measured is now drawn at its own height, and the measurement takes over from there. -
An
MPAnimate*entrance waiting for its trigger shows its content on a page read without JavaScript. Withtrigger="visible",manualorhoverthe content sits on the entrance's first frame, atopacity: 0or clipped, until script starts it — and with scripting off nothing ever does, so the content was in the markup and visible to nobody. The entrance is now dropped under@media (scripting: none), as it already was underprefers-reduced-motion. -
MPAnimateTyping,MPAnimateScrambleandMPAnimateSplitkeep the words of an element among their children. Their pages said an element gave its text and dropped its markup. It was dropped whole, soFree shipping on <a>every order</a>typed, read out and sent in the server-rendered markup only "Free shipping on". The element's text is now animated with the rest; its tag is still dropped. -
MPBreadcrumbkeeps the steps it folds behind…in the markup.maxItemstook the middle steps out of the page, so the trail a server sent no longer linked each level back to the top of the site, which is what a crawler reads a breadcrumb for. They stay in the markup now, hidden, after the…, and pressing it shows them. What is drawn and what a screen reader hears are unchanged. -
MPFormsubmits to itsaction. It always gave Base UI a handler for the form's values, and a handler is what makes Base UI cancel the native submit — so anactionwas cancelled too: a URL never navigated, a React server action never ran, and the form did nothing before the page had hydrated. With noonSubmit, a valid submit now goes to theaction. WithonSubmit, the values still come to it and nothing navigates, as documented. -
MPFilePickersubmits the files it shows. Its list is its own, and the native input under it only ever held the last batch the file dialog chose — not a dropped file, not the files chosen before, not a removal, and nothing after a dialog that was dismissed. A<form>, aFormDataor a server action reading the input sent the wrong files. The input now holds exactly what the list does. -
A chart draws a series of any length. The extents were found with
Math.min(...values), which runs out of argument room somewhere past a hundred thousand values and threw instead of drawing. The width estimate for CJK labels was also a character range written out literally, starting at a compatibility ideograph that Unicode normalisation rewrites — so a source decoded or normalised on the way to the page threw "Range out of order" and took every chart with it. It is written as escapes now. -
A chart inside a surface that is still scaling in is drawn to the room it has. Charts measured the size their box was drawn at, transforms included, so one mounted in an
MPDialog— which enters fromscale(0.95)— laid itself out five per cent narrower, and stayed that way: a transform finishing is not a resize, so nothing told it otherwise. They measure their box's layout size now. -
MPCarouselscrolls its own strip and never the page. It turned a slide withscrollIntoView, which scrolls every container the slide is inside — so each slide an autoplaying carousel turned pulled the page back to the carousel, while the reader was reading something else. Autoplay also holds while the carousel is scrolled out of view now, as it already did in a background tab. A slide chosen while the strip is out of view is turned at once, because Firefox does not run a smooth scroll nobody can see. -
MPCarouselopens ondefaultValueorvalue. A carousel asked to open on its third slide showed the first, while its marks and its live region said the third. -
MPOtpField's slots stay still as focus moves along them. The focus ring was a border that grew from one pixel to two, and a slot is the input itself, so on a page with nobox-sizingreset the focused slot grew by two pixels each way and pushed the slots after it and everything under the field. The second pixel is now an inset ring inside the border, and the ring looks the same.
Every release of this package is in the changelog on the docs site and in CHANGELOG.md at this tag.