Repository navigation
Material Plus 1.6.0
One shape, found twice.
It started as two additions of a kind — a control that had the answer to a question nobody could put to it — then a report from an application that moved off Material UI, which turned out to be the same hole in twenty more places. The calendar could already draw a grid of months and a grid of years, and there was no way to say "stop there and hand me that". Eight controls were producing keystrokes and focus changes every second they were used, and no prop took delivery of one. A row could be a link but not a router's link; a chip could be pressed but could not be a menu's trigger; a grid item could be six columns wide but not "the rest of the row".
Then the library was audited against MUI, Ant Design, Chakra, Mantine, Radix and PrimeReact — and the finding was the same sentence again, now about the library talking to itself. A complete calendar lived in internal/. So did a window-class store, a reduced-motion store, a shortcut matcher and the rule for screen-reader-only text. Nine components drew themselves with that last one and no application could reach it. Almost everything in Added below is a name being given to something that was already running.
The one genuinely new thing is MPConfigProvider, and it is the item that was costing the most — not because it was hard, but because it was costing at every call site: a design that runs at size="sm" had to say so on several hundred controls, each of them a place it could be forgotten.
Ten defects were found on the way, and finding them is the argument for auditing by running rather than by reading. Four change what an existing page draws and are in Changed rather than Fixed, because that is the section people read before upgrading:
MPTextFieldswallowed Enter on every single-line field, so a<form>it was dropped into stopped submitting.MPTypographypaintedon-surfacewhen its own documentation said it inherits.- A pressable
MPChipwas a<span>holding a<button>, so it could not be a Base UI trigger. - An
MPAccordionpanel went on clipping its contents after it had finished opening.
The six in Fixed include three that had been shipping in plain sight. A picker's chosen day was never painted at all — two independent causes, either one enough on its own. Base UI was never told which way the page runs, so a slider's handle sat exactly one handle-width from the value it reported under RTL, at every value. And forcing a region back to light did nothing, which the documentation had been promising since the scheme switches were written. Not one of the three was visible in a class name; each took measuring a computed style.
Nothing was renamed and nothing was removed. Every existing prop still means what it meant in 1.5.0.
Added
-
MPConfigProvider—size,colorandlocalefor a whole application. The gap that was costing the most, because it was costing at every call site: a design that runs atsize="sm"had to say so on each of several hundred controls, and each of those was a place it could be forgotten.<MPConfigProvider size="sm" color="tertiary" locale="ko"> <App /> </MPConfigProvider>
The resolution order is call site → group → provider → Material's own, so an
MPButtonGroupstill beats the page and a prop still beats everything. Ninety-eight resolution sites across sixty-five components changed from a destructuring default to that chain.Two axes and not three.
variantis deliberately absent:MPButtonstartsfilled,MPChipoutlined,MPAlerttonal,MPAccordionoutlined— five answers to five questions about emphasis, and one global value would overwrite all of them with an arbitrary one.sizeandcolorare the only axes where every component starts from the same answer, which is what makes a single default meaningful for them.The same rule decides which components read it: the provider supplies the library's default, not a component's answer.
MPBadgekeepserror,MPTooltipkeepssm, andMPDialog,MPPillandMPShortcutkeepsecondary— those are decisions rather than unfilled defaults. A prop with no default at all, likeMPSkeleton'scolor, is left unset too.It also carries
dir, which is the RTL wiring — seeFixedfor what was not connected. And it carrieslocaleso an application needs one provider rather than two.MPLocaleProvideris unchanged and still the narrow one; nesting merges per field, so an inner provider naming only a colour keeps the size from above.A note on the implementation, because the first version of it was wrong in a way worth recording: the resolver reads the context unconditionally. Written as
prop ?? React.useContext(…)the context is only read when the prop is absent, so a control handed asizeon one render and not on the next calls a different number of hooks. React said so out loud in the test run, and the fix was to read first and decide second. -
MPPopconfirm— the same questionuseMPConfirmasks, kept where the control is.The difference between the two is where the reader's eye is, and it is a real one. A row of twelve delete buttons is the case for this shape: a modal that covered the table would take away the row the reader was pointing at, and having to re-find it afterwards is how the wrong row gets deleted. A confirmation about the page — leaving with unsaved changes — still wants the modal, because it is not about a thing on the page at all.
It answers with a callback rather than a promise, which follows from the same difference: this one is written where the button is, so there is already a handler there to put it in. Escape, a press outside and the cancel button are all
onCancel, on the rule the modal already follows — the safe answer to "are you sure" is no. Opening is not an answer and fires neither handler.It is a popover and not a dialog, so it does not trap the focus and a reader can Tab away from the question. That is the trade the shape makes, and it is why the modal is still the one for an answer that has to be given. The labels come from the
confirmnamespace added above, so both components say the same words in the same eighteen languages. -
MPImage— a picture that says what it is doing.A bare
<img>has three states and shows two of them badly: while it is on its way there is a hole the size of nothing and the page jumps when it lands, and when it fails there is the browser's own broken-image mark, which is different in every browser and belongs to none of them. This draws a placeholder for the first and afallbackfor the second, andratioreserves the room so that neither one moves the page.The cached case is the one that breaks, and it is the reason this is a component rather than three lines of
useStateat a call site: an image already in the cache iscompletebefore React attaches anything, so itsloadevent has been and gone. A component that only listened would hold its placeholder over a picture that is fully drawn — on every second page view, which is the view nobody tests because the first one works.naturalWidthis what tells the two kinds ofcompleteapart.altis required, for the reasonMPIconButton'slabelis: a picture with no text alternative is the most common accessibility defect a library can actually help with, and the help is refusing to compile.alt=""is how to say decoration.previewmakes the box a button and opens the picture over a scrim — off by default, because one that silently became pressable would be a control nobody declared, and refused outright on a picture that failed.previewSrcis what makes a thumbnail worth being one.Not a gallery and not a
next/image: a lightbox that walked between pictures would be a component holding a collection, andsrcsetgeneration belongs to whatever is serving the file. One new glyph,broken-image. -
MPStepperandMPStep— the sequence a reader is working through, as opposed to the oneMPTimelineshows them having finished.The two draw the same picture, and now from the same table: the bullet, the connector and the ladder they sit on moved to
internal/step.ts, because a picture kept by two tables is a picture that drifts the first time one of them is edited alone. What makes them two components is that one is read and the other is pressed — and that a stepper mounts one panel, which is the half that matters for a form, since keeping the others would mean hidden fields that still submit.linearis on by default and going back is always allowed. What it refuses is jumping forward past the step the reader has actually got to — the furthest step rather than the current one, so a reader who reached step three and looked back at step one can still return to three. A stepper that only knewactivewould have taken that away from them.Three decisions worth naming:
- No Next and Back. What next means is whether the current step validates, and a library that drew those buttons would have to guess that or ask for a validator per step.
onActiveChangeand twoMPButtons is four lines the caller can read. erroris a colour, not a fourth state. Where a step sits in the run and what happened to it are two questions, and a sequence with a hole in it is one the reader cannot count — so a failed step keeps its place and swaps its accent family.optionaltakes a node rather than a boolean. A boolean would mean shipping the word Optional and then translating it into eighteen languages for a label only some applications draw.
A stepper with no
onActiveChangeis unpressable — a progress indicator for a sequence the application's own buttons drive. - No Next and Back. What next means is whether the current step validates, and a library that drew those buttons would have to guess that or ask for a validator per step.
-
MPConfirmProvideranduseMPConfirm— "are you sure?" as a promise.const sure = await confirm({ title: 'Delete this project?', confirmLabel: 'Delete', color: 'error' });
The dialog was never the hard part. A confirmation needs a piece of open state, a second piece for what is being confirmed, two handlers, and an
MPDialogkept mounted somewhere it does not belong — per call site. What a caller has at that moment is a click handler, and what they want back is a boolean. It is the tradeuseMPSnackbaralready makes, run the other way: a snackbar is something to say, this is something to ask.Every answer that is not the confirm button is
false— cancel, Escape, a press outside — and the promise never rejects, so a call site writes oneifrather than atryand a default.alert()is the one-button form and resolvesvoid: an acknowledgement has nothing to refuse, and a boolean nobody can vary is a value a caller would have to learn to ignore.One at a time rather than a queue. A second
confirm()raised while one is open replaces it and answers the firstfalse, because a queue would ask about something the reader has already moved past and the answer to a stale question is not information.coloris not defaulted toerror, deliberately: most confirmations are not destructive and a red button on every one of them stops being a warning. A newconfirmnamespace carries Confirm, Cancel and OK in all eighteen languages — a namespace of its own rather than three more entries incommon, which is the glyph labels rather than the words on buttons.Calling the hook with no provider throws. A promise that never settles is the hardest possible way to be told a provider is missing.
-
useMPColorSchemeandmpColorSchemeScript. The stylesheet has always had the switch —prefers-color-scheme, anddata-mp-schemefor a page that drives it itself — and nothing to drive it with. So every application wrote the same three things: a piece of state, alocalStorageround trip, and a script in the<head>to stop the first paint flashing.const { resolved, toggle } = useMPColorScheme();
Three states rather than two, and the third is the point.
'system'is the absence of a choice, not a scheme — a reader who has never touched the toggle follows their operating system as it changes, which is what a two-state hook stops doing at sunset.schemeis what was chosen andresolvedis what is painted; bind a settings control to the first and draw with the second.toggleexists separately because a two-way control cannot express "follow the system", and from'system'it goes to the opposite of what is on the screen rather than back to the scheme the reader is already looking at.The choice lives in a module-level store, so a header's toggle and a settings screen's radio group are looking at the same thing rather than each holding a
useStateand neither hearing the other.'system'removes the attribute rather than writing the word, which is how the media query gets its say back..darkis deliberately untouched: it is what a project's own Tailwind keys on, and moving a class this library did not put there is editing somebody else's markup.mpColorSchemeScript()returns the source for the<head>, because a hook runs after the browser has already painted and nothing else can run earlier. It reads the same key, writes the same attribute, does nothing when there is no stored choice, and JSON-encodes the key so one containing a quote cannot end the script early. It returns the source rather than the tag so a page under a strict CSP can add a nonce.Storage failing is handled rather than assumed away — it throws in a private window in some browsers and behind some cookie policies. The
typeof localStorageguard is inside thetryfor a reason worth recording: where a policy blocks storage the global is a throwing getter rather than a missing binding, sotypeofevaluates it like anything else and a guard outside thetrythrew before thetrycould catch it. A test blocking storage is what found that. -
MPVisuallyHidden. Text for a screen reader and nobody else, which nine components here were already drawing themselves with —MPPagination's live region,MPRating's radios,MPShortcut's key names,MPCarousel's slide announcement — and which an application putting a bare glyph in a button of its own had no way to reach. The library had the rule and not the name.The rule is Tailwind's
sr-onlywritten out, and it is written out for the reason the stylesheet ships no Preflight:sr-onlyis generated, so a project with a Tailwindprefixconfigured generates it under another name and a component that hardcoded it would come out visible on their page. The arbitrary properties survive any prefix.rendertakes it to another element — a hidden<h2>naming a landmark is the usual reason. Everything else is passed through,aria-liveincluded, which is how the components above use it. -
Four hooks, all of which the library was already running.
useMPWindowClass,useMPReducedMotion,useMPShortcutanduseMPPlatform, frommaterial-plus-uior frommaterial-plus-ui/hooks.None of them is new code so much as a name. The window size classes decide how
MPGridreflows and whereMPSidebarcollapses;prefers-reduced-motionis consulted by all elevenMPAnimate*components; the shortcut matcher is whatMPShortcutdraws from andMPCommandPalettebinds through. A consumer could name any of it in a prop and could not ask about any of it — so a page wanting one more decision than a prop covers wrote the breakpoints out again, in numbers that had to match the library's or the layout disagreed with itself at one width.useMPWindowClassreads the four boundaries throughmatchMediarather than offinnerWidth, which is the difference between agreeing with the stylesheet and agreeing with arithmetic: a classic scrollbar is counted by one and not the other, so a 615px window ismediumto CSS andcompactto a subtraction. It subscribes to the boundaries rather than toresize, so a drag from 500 to 1900 wakes it four times instead of several hundred. Where there is no window it answers itsonServerargument,'expanded'by default — a guess, and an argument because an application usually knows better than the library which way its own first paint should lean.useMPShortcuttakes the spellingMPShortcutdraws, so<MPShortcut keys="Mod+K" />anduseMPShortcut('Mod+K', open)cannot end up as two different combinations.preventDefaultis on by default because a page that bindsMod+Khas said the key is theirs;ignoreInputsis off by default becauseMod+Ktyped into a search field still means "open the palette", and is there for the bare/that would otherwise be taken out of the middle of a sentence.WINDOW_CLASSESmoved out ofMPGridon the way, so the grid and the hook read one table. -
MPCalendar, the calendar without the picker in front of it.The grid
MPDatePickeropens was always the larger half of that component — three views on one footprint, a roving tab stop, arrow keys that step the month at an edge, a header where the month name and the year are each a button into a grid of their own — and there was no way to put it on a page. A booking page that shows the month it is talking about, a dashboard with the calendar beside the list it filters, a form with room for it: in all three the popup is the part that is in the way.It is a wrapper rather than a re-export, and the wrapper is the whole of the work.
internal/Calendaris fully controlled and takes itslabelsalready resolved, because the four callers it was written for had each decided those things; a consumer has not, soMPCalendaris where the value, the month on screen and the translation are put back. The internal component gave up theMPprefix in the process — it belongs to what a consumer can import.Three decisions that are not the picker's, each for a reason:
variantdefaults to'text'and paints no surface at all. A standalone calendar almost always lands somewhere that is already one — a card, a panel, a popover of your own — and a default that painted a second would be a box inside a box in the common case. The other four are the container ladder, and they take the popup's padding table so that a calendar standing alone and the identical calendar inside a picker are not one track apart.- The month on screen stays where the reader left it. The picker resets it every time the popup opens, because opening says start again; a calendar that is always on screen has no such moment, and one that snapped back to July while somebody was reading September would be undoing a navigation they meant.
monthandonMonthChangedrive it for the cases that want to. onValueChangenever hands overnull. There is no × on a calendar, and a control that unchose itself on a second press would lose a value to a double-click.
autoFocusisfalsehere andtrueinside a picker, which is the difference between the two in one prop: a popup that has just opened is the reader's current business, and a calendar sitting in a page is not.Not a range calendar and not an event calendar — the first is
MPDateRangePicker's question, and the second wants a per-cell rendering hook that would stop a cell being a 40dp target. -
precisiononMPDatePicker—'day','month'or'year'. A card's expiry, a fiscal year, the month a report covers: the questions where a day is not something the reader has to give, and where a control that asked for one anyway would be recording an answer nobody meant.It stops the calendar by leaving the finer views out rather than by refusing them. A month picker opens on the twelve months and has no day grid to reach through, so a press there is the answer instead of a way down; a year picker opens on the years. The view above the one it answers with stays, because that is how the other years are reached — pressing the year on a month picker opens the page of years, and choosing one comes back to the months.
Everything downstream follows the same word, which is the part worth having in a table:
'day''month''year'The value the day pressed the 1st of the month 1 January The trigger, by default Jul 15, 2026July 20262026namesubmits2026-07-152026-072026The footer's shortcut Today This month This year The value is trimmed to the unit because it stands for the unit: a picker whose trigger says July 2026 and whose form submits the 31st is printing one thing and sending another. The time of day survives, exactly as it already did at day precision.
minDateandmaxDateare read at the precision too, which is the rule the month grid and the year grid were each already spelling out for themselves —isUnitOutsideininternal/date.tsstates it once and both now ask it. A minimum of 10 July leaves July pickable on a month picker, because there the bound is about which months exist.shouldDisableDateis the one thing that does not carry over: it is asked about days, and a rule written about weekends has no answer for "is July available". Inventing one out of the 1st would block whichever months happened to start on a Sunday, so a coarser picker never calls it.Two new strings,
thisMonthandthisYear, in thepickernamespace and in all eighteen translations. -
MPControlEventProps, on the eight controls that could not take an event handler at all.onKeyDown,onKeyUp,onFocus,onBlur,onClick,onDoubleClickandonContextMenu, onMPTextField,MPNumberField,MPSelect,MPComboboxand the four date and time pickers.Seventy-four of the hundred and fourteen exported prop types already accepted every one of these, by extending
React.ComponentPropsWithoutRefand spreading the rest onto an element they own. The forty that did not are the same shape of componentclassNamewas missing from in 1.5.0, and for the same reason: a control that draws a box, a label, an input and a supporting line has four elements a handler could land on, and spreading would pick one of them by accident.So the rule is written down instead, and it is the opposite of where a class goes. A class describes the whole component and lands on its outermost element; an event came from one element and lands on the control that produced it. On an
MPTextFieldthat meansonKeyDownis a keystroke that landed in the field and never one that landed on the reveal toggle beside it, andonFocusis the input taking focus rather than anything in the row taking it. Each props table names the element.The other half is ordering. Yours runs first, and a control with its own answer for a key checks
defaultPreventedbefore giving it:<MPTextField value={draft} onChange={setDraft} rows={4} onSubmit={send} // plain Enter onKeyDown={(event) => { if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { event.preventDefault(); // ⌘Enter is yours — and no newline is inserted sendAndClose(); } }} />
A
<div onKeyDown>wrapped around the control gets the keystroke by bubbling, which is what a caller had to write before. What it cannot do is go first.MPTextField's composition rule is unchanged: the Enter that commits a syllable is reported to a caller, because interpreting it is theirs to do, and is still never turned into a submission.MPOtpFieldis deliberately not among the eight. It is six inputs rather than one, and ablurthat fired every time the caret moved between two of its boxes would be reporting something that did not happen.MPDialogjoins them, and it is the one that is not a control. Its props are a closed set rather than a spread, so there was no element a caller could reach at all — a Ctrl+A that belongs to the dialog's own body had to be caught on a wrapper outside the component. The handlers land on the sheet, which is where a keystroke inside the dialog arrives by bubbling; the trigger that opened it and the page behind it are both somewhere else. Base UI's own handling of the keys it owns, Escape included, runs alongside rather than being replaced. -
span="grow"onMPGridItem. The one width that is not a number of columns: whatever the row has left after everybody else has taken theirs.A thumbnail beside a body of text is the layout it exists for, and the reason a
spancould not express it is that the remainder is only known once the other items in that row have been laid out.span={3}andspan={9}is the same picture right up until the thumbnail's column count changes and the two stop adding up. Two growing items split the remainder equally rather than in proportion to their contents, and it is responsive like any other value —{ compact: 12, medium: 'grow' }.The arithmetic is a switch rather than a second rule, because a custom property cannot choose between two of those: the width declaration multiplies by
1 - grow, so a growing item measures nought and is handed the row's remainder byflex-grow, which is the only place that remainder is known. Aspanthat never mentions'grow'emits no extra property, so the common case is the one inline value it always was. -
marksonMPSlider. The ticks on the track, and optionally what is written under them.trueputs one at everystep, which is MD3's discrete slider; an array names them instead and is the form that can carry labels —[{ value: 1990, label: '1990' }, …]for the decade markings under a year range.A tick over the filled part of the track takes the accent's own ink and one over the groove takes
on-surface-variant, which is the specification's pairing and the reason a tick stays visible as the handle passes it. That needs the current value, so the slider now mirrors it — an uncontrolled slider's value lives inside Base UI, and a tick that could not see it would be drawn in the groove's colour on top of the accent, which is the one place a 3px dot disappears entirely.Three limits, all of them stated rather than silent. The boolean form draws nothing past fifty ticks, at which point they are a dotted line built out of one DOM node each and say less than no ticks would. A mark outside
min…maxis dropped rather than clamped, because two pinned to the same end read as one. And labels are laid out from each tick's centre and are not measured, so two that would collide overlap. -
filteronMPCombobox. Which rows survive the query, in place of the matching the component does on its own — andnullturns the filtering off entirely.That is the value the prop exists for. A list fetched per keystroke has been matched by a server that knows things this does not — synonyms, transliteration, the rule that strips a punctuation mark out of a tag before comparing it — and a second pass here can only remove rows that server decided were hits. A row the reader can see the reason for, disappearing as they type the next character, is what that looks like. A function is the middle ground. The row that offers what was typed is exempt either way: its label is the query, so a filter that hid it would be hiding the answer to the question it was asked.
-
chipVariantandchipColoronMPCombobox, andcontentonMPComboboxOption.The chips in a
multiplefield weretonalinprimarywith no way to say otherwise, and a field holding six of those reads as a row of buttons rather than as a value — which is why Material UI's own autocomplete draws them outlined. The defaults are unchanged; there are now two props instead of a stylesheet override.contentis what a row draws when that is more than its label: a thumbnail, a glyph, a second line. It replaces the label in the popup only — the input still receiveslabelwhen the row is chosen, the chip still showslabel, and the filter still matches against it. That is why it is a second prop rather thanlabelbeing loosened to aReactNode: a text input's value is a string, and a row that could not say what its own string is would have nothing to put there. -
renderonMPButton, which its own documentation had been recommending since 1.0 and whichMPButtonPropsdid not have. A link wearing a button is what it is for, and it is three props rather than one:<MPButton render={<a href="/pricing" />} nativeButton={false} role="link"> Pricing </MPButton>
nativeButtonis Base UI's, passed through, androleputs back the one thing it then assumes:falseis taken to mean the element is acting as a button, so Base UI gives itrole="button"to say so. For an anchor that is one assumption too many, and without the third prop the link is announced as a button — exactly the lie the component's "nohref" rule exists to prevent. The rule's own paragraph now spells all three out instead of pointing at a prop that was not there. -
render,targetandrelonMPListItemandMPBottomNavigationItem. Both drew a plain<a>with no way to reach it, so a Next or React Router application could not have a client-side navigation or a prefetch on a row, and an external link could not open in a new tab without being turned into a button.renderon a list row is the onerenderin the library that is not the outermost element, and that is worth stating rather than discovering. A row's shell is an<li>because it is inside a<ul>, and swapping that makes the list stop being a list; what a caller wants to replace is the<a>inside it, which is the element a router has to own.targetbrings therela new tab needs, and arelof your own replaces it rather than extending it — the bargain the other four link components already documented. -
nativeButtononMPMenu. For the trigger that is deliberately not a<button>: an avatar, a card, a row of text. Base UI then supplies the role, the tab stop and the Enter/Space handling, so the trigger is still a button to a screen reader and to a keyboard. It is a prop rather than something inferred because what a trigger renders is only known once it has rendered, and by then the wiring has been decided. -
hideLabelon the three progress indicators. The name stays in the accessibility tree and stops taking a line, which is what thearia-labelit replaced used to do.There were two options before this and both were wrong for the indicator that is already labelled by what is around it — a spinner inside a card whose heading says what is loading. Drawing the name a second time is noise a sighted reader has to skip; removing it leaves a
progressbarannounced as "63%" and nothing else, which is a number with no subject. -
type="search"onMPTextField. For the keyboard it summons and the autofill it declines: iOS labels the return key "search" for it, and a browser keeps its own history of what was typed into one. It draws exactly astextdoes, and WebKit's own × inside it is suppressed — that mark sits outside the adornment row, is not in the tab order, and empties a controlled value without telling React. -
positionandclassNameper snackbar.positionwas a property of the provider, so a message that had to be somewhere else because of what it is — an error at the top of a page whose bottom corner is a toolbar — had nowhere to go.All six stacks are now on the page from the first render whether or not anything is in them, and that is the part worth knowing: a stack is its own
aria-liveregion, and a region added to the document at the same instant as the message inside it is one a screen reader has nothing to compare against and does not read. An empty stack is apointer-events-noneflex column with no children. -
Class hooks on the labels that had none.
mp-list-item__label,mp-list-item__description,mp-list-item__text,mp-accordion__title,mp-accordion__subtitleandmp-accordion__text. A page that wanted to resize a row's title had.truncateto aim at, which is a utility on hundreds of other elements.
Changed
-
MPTextFieldno longer swallows Enter on every single-line field. It swallows it when there is anonSubmitto have answered it, or whendisableEnterKeysays so outright, and otherwise leaves the key to the browser — which inside a<form>is the native submit an<input>has always done.The old behaviour was defensible one field at a time and indefensible in aggregate: the comment explaining it says "so a surrounding form is not submitted on top of whatever
onSubmitjust did", which is a good reason that applied whether or not there was anonSubmit. A page that already wrote<form onSubmit>— the ordinary way to write a form — got a field that silently stopped it. No type error, no console warning, and nothing to see until somebody pressed Enter.disableEnterKeynow means the same sentence on both shapes of field: take the key away from whatever would otherwise have it. The newline withrows, the form's submit without. Multiline behaviour is otherwise unchanged, including thatonSubmitthere reports the keystroke without deciding what the field does with it. -
MPTypographydeclares no colour at all unlesscoloris passed. Which is what its own prop documentation has said since it shipped — "no default: prose inherits the surface's own ink unless a role is asked for" — and not what it did.It wrote
[&.mp-typography]:text-mp-on-surface, andcaptionandoverlinegoton-surface-variant. The doubled selector is two classes, so a page could not win: prose dropped into a dark hero section withtext-whiteon it came outon-surface, and the way out was to stop using the component. "No default" has to mean no declaration, because a default written at that specificity is not a default, it is a decision.What goes with it:
captionandoverlineare no longer muted for you. That was a choice about what the text is rather than something a size can decide, and it is aclassName="text-mp-on-surface-variant"away — which now works, for the same reason the rest of this entry does. -
A pressable
MPChipis a<button>rather than a<span>holding one. One element, one tab stop, and one thing for a parent to hangaria-expandedon.The old shape was there for a real reason and it still is: a chip with
onDeletehas two controls, and a<button>inside a<button>is markup the browser un-nests on parse. So a chip with both keeps the span shell and two sibling buttons. Everything else — which is the common case — collapses to one.What it fixes is the chip as a trigger. Base UI's
rendermerges its handlers and its ARIA onto the element the component returns, soaria-haspopup,aria-expanded,idandtabindexwere landing on a<span>that was not focusable while the real button underneath was a second tab stop carrying none of it — and Base UI logged that it expected a native<button>.<MPMenu trigger={<MPChip>Filter</MPChip>}>now works, and is less markup than it was.The surface is unchanged at every variant, size and state:
appearance-nonetakes the browser's button styling off, and the variant table is still the only thing painting a chip. -
An
MPAccordionpanel stops clipping once it has finished opening.overflow: hiddenis the animation's, not the panel's — it exists so a body mid-open is a window rather than a squashed copy of itself.A settled panel that went on clipping cut the top off the first thing in it that draws outside its own box. A text field's floating label is exactly that: it sits on the field's top edge, which is the panel's top edge, so a form in an accordion arrived with its first label sliced in half and nothing in the console said so. A select's popup, a tooltip and a focus ring are the same bug with different pixels.
Three signals decide it, because no one of them covers every way a panel arrives.
onOpenChangeputs the clip back before the height moves, which is the only one early enough to. The height'stransitionendtakes it off again — Base UI'stransitionStatuscannot, because it marks the first frame and the last rather than the stretch between them. And a callback ref covers the two panels that never transition at all, a section that started open and any section under reduced motion, both of which would otherwise wait for an end that is not coming.
Fixed
-
A month cell told a Korean reader
7월 2026. The month grid's cells were named by sticking the year on the end of a month name, which is the order English uses and one that Korean and Japanese do not. They areIntl's now —2026년 7월,January 2026— which is the ordering the header's two buttons have always been put in and for the reason they are: a date in the wrong order reads as broken to exactly the readers it is wrong for.Tolerable while that grid was the middle of a day picker's journey. Not tolerable now: on a picker whose
precisionis a month, those twelve strings are everything a reader hears. -
The month grid and the year grid were announced as "grid" and nothing else. They are named by the year and by the page of years they show, the way the day grid has always been named by its month.
-
Base UI was never told which way the page runs. Every padding, margin, corner and icon slot here is already a logical property, so the stylesheet half of RTL has worked from a
diranywhere above a component since the beginning. Base UI answers the same question from a React context, and nothing in this library was providing one — so all seven of its parts that read it (the slider, menu, select, combobox, navigation menu, OTP field and scroll area) were being told the page ran left to right, whatever the DOM said.The slider is where it showed. Its thumb is placed with
inset-inline-startand a physicaltranslate: -50%: under RTL the inset measures from the right and the translate still moves left, so the handle sat exactly one handle-width from the value it was reporting — at every value, and off the end of the track at 0 and at 100. That is the shape the direction test file's own preamble warns about: RTL decays one physical value at a time, and the only thing that holds it is asking.dironMPConfigProvideris the wiring, and it sets both halves: the attribute on adisplay: contentselement, which takes part in no layout, and the same value in Base UI's ownDirectionProvider. Left out, nothing is rendered and nothing is claimed. -
Forcing a region back to light did nothing. The dark block applies to any element carrying the attribute, so a dark section inside a light page has always been one attribute — and the documentation said the reverse worked too. It did not: the light scheme's tone stops lived on
:rootalone, so under a system preference of dark a[data-mp-scheme='light']on a section matched no rule at all. The media query had already retuned:root, the section inherited that, and nothing put the light stops back.The light stops now sit on
:root, [data-mp-scheme='light'], which makes the two directions symmetrical.--mp-source-colorstays on:rootalone and above them, deliberately: repeated on the light block it would override a consumer's own scoped source colour on any element that also asked for light. -
A picker's chosen day was never painted. The filled circle MD3 puts under the selected date, the tonal band across a chosen range, and today's outlined ring are all drawn from
--_mp-accent— and none of the three arrived. Two independent causes, either of which was enough on its own:The popup is a portal into
document.body, andMPPickerShelldeclared the four accent slots on its root only. Nothing the shell sets reaches a portal, so inside the popup--_mp-accentresolved to nothing at all.MPMenuhad always put them on its own popup; the pickers had not.And the cell's base carried
bg-transparentto clear the native button background, in the same class list as the state'sbg-(--_mp-accent). Two utilities of equal specificity resolve by their order in the generated stylesheet, not by the order they are written in the attribute, and Tailwind emits.bg-transparentafter.bg-(--_mp-accent)— so even where the property did resolve, the reset won. This is the exact hazard the cell's own comment describes and the reason its states are anif/elsechain; the base was a third utility in a race the chain was built to avoid. Every branch now names its own background, including the four that paint none.MPDatePicker,MPDateRangePicker,MPDateTimePickerandMPTimePickerare all affected, and the clock columns had the second bug too. Three tests now assert the computed background rather than the class list, which is the difference between checking that a rule was written and checking that it applies. -
A nested
MPGridItemresolved the outer item's span. The slots an item lays itself out from are inherited custom properties, which is deliberate — a media query can change one without React hearing about it — but a grid inside an item sits inside that item's--_mp-span-large, so an inner item that only declaredcompactresolved the outer one at every class above it. Aspan={12}came out a sixth of the row it was actually in, and the archive card whose title wrapped one letter at a time was 26px inside a 157px box.A grid is where a span stops meaning anything, so a grid is where the slots go back to nothing:
.mp-gridresets the five span slots and the five offset slots toinitial. Fifteen declarations once per grid, and the arithmetic under them is unchanged. The documentation site's own nesting example was affected by this.
The measurements
npm run measure, against a build of this release and a build of af10fcc for the baseline, so the difference is the change and not drift.
| Scenario | 1.5.0 | 1.6.0 |
|---|---|---|
MPBox |
0.4 kB | 0.4 kB |
MPButton |
2.9 kB | 3.0 kB |
MPTextField |
4.3 kB | 4.4 kB |
| Five components | 7.4 kB | 7.5 kB |
| Ten components | 11.2 kB | 11.6 kB |
| Everything | 76.1 kB | 81.5 kB |
Just under two and a half kilobytes across the whole library, and it is spread rather than concentrated: the calendar's precision, the slider's ticks, the combobox's filter adapter, the accordion's three transition signals, and a useRender call in each of two list components. MPButton and MPTextField move a tenth each — the button for render and nativeButton, the field for one conditional class — which is what a minifier leaves of a handful of new names.
The last 5.4 kB is this report's own — MPCalendar, the five hooks, MPVisuallyHidden, MPConfigProvider, MPConfirmProvider, MPStepper, MPImage, MPPopconfirm, and the cell's four extra bg-transparent branches. Three of those four are close to free, because the grid the calendar draws and the machinery the hooks name were both already in everything and what they cost is a wrapper each: material-plus-ui/hooks is 0.3 kB imported on its own, and MPVisuallyHidden is one class string.
MPConfigProvider is the one that is not free anywhere, and it is worth being explicit about why. Every component that resolves a size or a color now imports internal/config, so the module reaches any bundle holding any of them — which is what moves ten components from 11.2 kB to 11.6 kB, a shade under a tenth of a kilobyte per component and one shared module rather than a per-component cost. MPButton alone goes from 23 modules to 24. A page that renders one control pays about 40 bytes for the ability to configure several hundred.
The stylesheet grew from 113.5 kB to 114.6 kB, and 16.2 kB to 16.4 kB gzipped. Fifteen reset declarations on .mp-grid, one overflow-visible, one search-cancel-button rule and a tick's five sizes are the whole of it; the crossover at about thirty-one components is unchanged. The split sheets go from eighty-eight to ninety-one — MPCalendar, MPVisuallyHidden and MPConfigProvider get one each, and none of the three holds a hand-written rule.
The suite goes from 1644 tests to 1924. Nineteen are the date picker's two new units and the raw events, as before. Twenty-seven are MPCalendar's, forty-five the hooks', ten MPVisuallyHidden's, seventeen MPConfigProvider's, eight the scheme switches, six the direction wiring, fifteen useMPConfirm's, nineteen MPStepper's, twenty MPImage's, eleven MPPopconfirm's, and three more the picker's chosen day — those last ones read a computed background rather than a class list, which is the difference between checking that a rule was written and checking that it applies. The other eighty are this report, and the ones worth naming are the ones that would have caught the defects rather than described them: a nested grid item measuring 200px instead of 33px, a form actually submitting on Enter, a panel's overflow read at four points across its animation in both the controlled and uncontrolled cases, and a chip trigger asserting there is exactly one tab stop rather than two.
Every release of this package is in the changelog on the docs site and in CHANGELOG.md at this tag.