Skip to content

Material Plus 1.6.0

Choose a tag to compare

@jooy2 jooy2 released this 04 Oct 08:03
· 423 commits to main since this release

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:

  • MPTextField swallowed Enter on every single-line field, so a <form> it was dropped into stopped submitting.
  • MPTypography painted on-surface when its own documentation said it inherits.
  • A pressable MPChip was a <span> holding a <button>, so it could not be a Base UI trigger.
  • An MPAccordion panel 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, color and locale for a whole application. The gap that was costing the most, because it was costing at every call site: a design that runs at size="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 MPButtonGroup still 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. variant is deliberately absent: MPButton starts filled, MPChip outlined, MPAlert tonal, MPAccordion outlined — five answers to five questions about emphasis, and one global value would overwrite all of them with an arbitrary one. size and color are 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. MPBadge keeps error, MPTooltip keeps sm, and MPDialog, MPPill and MPShortcut keep secondary — those are decisions rather than unfilled defaults. A prop with no default at all, like MPSkeleton's color, is left unset too.

    It also carries dir, which is the RTL wiring — see Fixed for what was not connected. And it carries locale so an application needs one provider rather than two. MPLocaleProvider is 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 a size on 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 question useMPConfirm asks, 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 confirm namespace 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 a fallback for the second, and ratio reserves 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 useState at a call site: an image already in the cache is complete before React attaches anything, so its load event 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. naturalWidth is what tells the two kinds of complete apart.

    alt is required, for the reason MPIconButton's label is: 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.

    preview makes 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. previewSrc is 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, and srcset generation belongs to whatever is serving the file. One new glyph, broken-image.

  • MPStepper and MPStep — the sequence a reader is working through, as opposed to the one MPTimeline shows 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.

    linear is 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 knew active would 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. onActiveChange and two MPButtons is four lines the caller can read.
    • error is 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.
    • optional takes 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 onActiveChange is unpressable — a progress indicator for a sequence the application's own buttons drive.

  • MPConfirmProvider and useMPConfirm — "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 MPDialog kept 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 trade useMPSnackbar already 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 one if rather than a try and a default. alert() is the one-button form and resolves void: 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 first false, because a queue would ask about something the reader has already moved past and the answer to a stale question is not information.

    color is not defaulted to error, deliberately: most confirmations are not destructive and a red button on every one of them stops being a warning. A new confirm namespace carries Confirm, Cancel and OK in all eighteen languages — a namespace of its own rather than three more entries in common, 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.

  • useMPColorScheme and mpColorSchemeScript. The stylesheet has always had the switch — prefers-color-scheme, and data-mp-scheme for a page that drives it itself — and nothing to drive it with. So every application wrote the same three things: a piece of state, a localStorage round 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. scheme is what was chosen and resolved is what is painted; bind a settings control to the first and draw with the second. toggle exists 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 useState and neither hearing the other. 'system' removes the attribute rather than writing the word, which is how the media query gets its say back. .dark is 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 localStorage guard is inside the try for a reason worth recording: where a policy blocks storage the global is a throwing getter rather than a missing binding, so typeof evaluates it like anything else and a guard outside the try threw before the try could 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-only written out, and it is written out for the reason the stylesheet ships no Preflight: sr-only is generated, so a project with a Tailwind prefix configured generates it under another name and a component that hardcoded it would come out visible on their page. The arbitrary properties survive any prefix.

    render takes it to another element — a hidden <h2> naming a landmark is the usual reason. Everything else is passed through, aria-live included, which is how the components above use it.

  • Four hooks, all of which the library was already running. useMPWindowClass, useMPReducedMotion, useMPShortcut and useMPPlatform, from material-plus-ui or from material-plus-ui/hooks.

    None of them is new code so much as a name. The window size classes decide how MPGrid reflows and where MPSidebar collapses; prefers-reduced-motion is consulted by all eleven MPAnimate* components; the shortcut matcher is what MPShortcut draws from and MPCommandPalette binds 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.

    useMPWindowClass reads the four boundaries through matchMedia rather than off innerWidth, 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 is medium to CSS and compact to a subtraction. It subscribes to the boundaries rather than to resize, so a drag from 500 to 1900 wakes it four times instead of several hundred. Where there is no window it answers its onServer argument, '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.

    useMPShortcut takes the spelling MPShortcut draws, so <MPShortcut keys="Mod+K" /> and useMPShortcut('Mod+K', open) cannot end up as two different combinations. preventDefault is on by default because a page that binds Mod+K has said the key is theirs; ignoreInputs is off by default because Mod+K typed 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_CLASSES moved out of MPGrid on the way, so the grid and the hook read one table.

  • MPCalendar, the calendar without the picker in front of it.

    The grid MPDatePicker opens 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/Calendar is fully controlled and takes its labels already resolved, because the four callers it was written for had each decided those things; a consumer has not, so MPCalendar is where the value, the month on screen and the translation are put back. The internal component gave up the MP prefix in the process — it belongs to what a consumer can import.

    Three decisions that are not the picker's, each for a reason:

    • variant defaults 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. month and onMonthChange drive it for the cases that want to.
    • onValueChange never hands over null. There is no × on a calendar, and a control that unchose itself on a second press would lose a value to a double-click.

    autoFocus is false here and true inside 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.

  • precision on MPDatePicker — '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, 2026 July 2026 2026
    name submits 2026-07-15 2026-07 2026
    The 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.

    minDate and maxDate are read at the precision too, which is the rule the month grid and the year grid were each already spelling out for themselves — isUnitOutside in internal/date.ts states 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. shouldDisableDate is 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, thisMonth and thisYear, in the picker namespace and in all eighteen translations.

  • MPControlEventProps, on the eight controls that could not take an event handler at all. onKeyDown, onKeyUp, onFocus, onBlur, onClick, onDoubleClick and onContextMenu, on MPTextField, MPNumberField, MPSelect, MPCombobox and 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.ComponentPropsWithoutRef and spreading the rest onto an element they own. The forty that did not are the same shape of component className was 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 MPTextField that means onKeyDown is a keystroke that landed in the field and never one that landed on the reveal toggle beside it, and onFocus is 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 defaultPrevented before 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.

    MPOtpField is deliberately not among the eight. It is six inputs rather than one, and a blur that fired every time the caret moved between two of its boxes would be reporting something that did not happen.

    MPDialog joins 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" on MPGridItem. 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 span could not express it is that the remainder is only known once the other items in that row have been laid out. span={3} and span={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 by flex-grow, which is the only place that remainder is known. A span that never mentions 'grow' emits no extra property, so the common case is the one inline value it always was.

  • marks on MPSlider. The ticks on the track, and optionally what is written under them. true puts one at every step, 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…max is 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.

  • filter on MPCombobox. Which rows survive the query, in place of the matching the component does on its own — and null turns 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.

  • chipVariant and chipColor on MPCombobox, and content on MPComboboxOption.

    The chips in a multiple field were tonal in primary with 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.

    content is 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 receives label when the row is chosen, the chip still shows label, and the filter still matches against it. That is why it is a second prop rather than label being loosened to a ReactNode: 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.

  • render on MPButton, which its own documentation had been recommending since 1.0 and which MPButtonProps did 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>

    nativeButton is Base UI's, passed through, and role puts back the one thing it then assumes: false is taken to mean the element is acting as a button, so Base UI gives it role="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 "no href" 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, target and rel on MPListItem and MPBottomNavigationItem. 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.

    render on a list row is the one render in 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. target brings the rel a new tab needs, and a rel of your own replaces it rather than extending it — the bargain the other four link components already documented.

  • nativeButton on MPMenu. 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.

  • hideLabel on the three progress indicators. The name stays in the accessibility tree and stops taking a line, which is what the aria-label it 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 progressbar announced as "63%" and nothing else, which is a number with no subject.

  • type="search" on MPTextField. 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 as text does, 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.

  • position and className per snackbar. position was 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-live region, 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 a pointer-events-none flex 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__subtitle and mp-accordion__text. A page that wanted to resize a row's title had .truncate to aim at, which is a utility on hundreds of other elements.

Changed

  • MPTextField no longer swallows Enter on every single-line field. It swallows it when there is an onSubmit to have answered it, or when disableEnterKey says 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 onSubmit just did", which is a good reason that applied whether or not there was an onSubmit. 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.

    disableEnterKey now means the same sentence on both shapes of field: take the key away from whatever would otherwise have it. The newline with rows, the form's submit without. Multiline behaviour is otherwise unchanged, including that onSubmit there reports the keystroke without deciding what the field does with it.

  • MPTypography declares no colour at all unless color is 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, and caption and overline got on-surface-variant. The doubled selector is two classes, so a page could not win: prose dropped into a dark hero section with text-white on it came out on-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: caption and overline are no longer muted for you. That was a choice about what the text is rather than something a size can decide, and it is a className="text-mp-on-surface-variant" away — which now works, for the same reason the rest of this entry does.

  • A pressable MPChip is a <button> rather than a <span> holding one. One element, one tab stop, and one thing for a parent to hang aria-expanded on.

    The old shape was there for a real reason and it still is: a chip with onDelete has 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 render merges its handlers and its ARIA onto the element the component returns, so aria-haspopup, aria-expanded, id and tabindex were 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-none takes the browser's button styling off, and the variant table is still the only thing painting a chip.

  • An MPAccordion panel stops clipping once it has finished opening. overflow: hidden is 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. onOpenChange puts the clip back before the height moves, which is the only one early enough to. The height's transitionend takes it off again — Base UI's transitionStatus cannot, 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 are Intl'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 precision is 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 dir anywhere 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-start and a physical translate: -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.

    dir on MPConfigProvider is the wiring, and it sets both halves: the attribute on a display: contents element, which takes part in no layout, and the same value in Base UI's own DirectionProvider. 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 :root alone, 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-color stays on :root alone 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, and MPPickerShell declared the four accent slots on its root only. Nothing the shell sets reaches a portal, so inside the popup --_mp-accent resolved to nothing at all. MPMenu had always put them on its own popup; the pickers had not.

    And the cell's base carried bg-transparent to clear the native button background, in the same class list as the state's bg-(--_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-transparent after .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 an if/else chain; 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, MPDateTimePicker and MPTimePicker are 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 MPGridItem resolved 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 declared compact resolved the outer one at every class above it. A span={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-grid resets the five span slots and the five offset slots to initial. 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.