Repository navigation
Material Plus 1.7.0
A second report from the application that filed the first one, a day after upgrading to 1.6.0 — and then the largest addition in the library's history, which the report is only the first section of.
Ten of the fifty additions are charts, and they are the release. Nothing here drew data before: an application that wanted a line over eight months reached for a charting library and got a second design system with it — its own colours, its own type, its own idea of what a tooltip is. The ten are MPStatistic, MPSparkline, and the eight that need a frame: line, area, bar, pie, scatter, gauge, heatmap and timeline.
They were built frame first, with one consumer. MPLineChart was written against internal/ChartFrame.tsx before any of the others existed, because a frame written for nobody fits nobody; the area chart then arrived against it unchanged, which was the test of whether that had worked. Two things came out of the seven that followed rather than being designed in — the chrome split into internal/ChartChrome.tsx when the pie turned out to need a legend and none of the axes, and a second value axis when the scatter turned out to need an x that was a position rather than a column heading. Neither could have been guessed at the start, and both were cheap once something concrete asked for them.
The colour was computed, not chosen. The obvious design was to rotate a series ramp off --mp-source-color, so a themed page got a themed palette for free. It was tried, measured, and it does not work: colour-vision deficiency collapses the red–green axis at absolute hues, so a ramp fitted to clear that axis puts a different adjacent pair straight onto it the moment the whole thing is rotated — four of five test seeds failed. The eight slots are fixed hues with only their lightness following the scheme, fitted until every adjacent pair clears ΔE 8 under protanopia and deuteranopia and ΔE 15 under ordinary vision, in both schemes. The heatmap's sequential ramp was fitted the same way against its own checks. Three is the cap where any two marks can touch — a scatter, a bubble, a heatmap — and no ordering of eight does better, which is why the scatter's marks carry shapes and the heatmap's cells carry a ramp instead.
Every chart ships a hover layer, a keyboard walk over the same readings, a clipped live region that is a sibling of the picture rather than a child of it, and the data as a table behind it. MPSparkline is the one exception and its documentation argues the case: it is thirty pixels tall with no axis, and a card floating over a mark that size covers the mark.
The other forty additions are the rest of the report: MPDataTable, MPTreeView, MPTreeSelect, MPTour, MPCodeBlock, MPMockup, MPHoverCard, MPToolbar, MPDataList, MPAppLogo, MPMeter, MPAnchor, MPScrollArea, MPScrollZone, MPPortal, MPFlex, MPStack, MPShow, MPFloatingBottomNavigation, four hooks, six more motion components, and four props — density, elevation, transition and classNames — given to every component that had the thing and no name for it.
One component was removed, which has not happened before. MPAvatarGroup was a special case of stacking that happened to be about faces; MPStack is the general one, and every existing use of the group is a MPStack with the same props. It is the only breaking change in the release.
Eleven defects were fixed, and four of them had been shipping in plain sight: eleven modules went out without "use client", one of which took a whole site down on a server component; the build never emptied dist/, so a deleted module went on being published; a picker asked for without its glyph could not be opened with a mouse at all; and a nested grid resolved the outer grid's column count. Three more were found by looking at rendered pages rather than at code, which is the argument for looking.
Added
-
MPTimelineChart, the last of the ten, and the only one whose marks have two ends. Every other chart here puts a mark at a single point on the value axis, which is why a span is its own type rather than anendbolted ontoMPChartPoint— a field the other seven would carry and never read.The axis is a calendar rather than a number line. The 1-2-5-10 steps every other value axis uses put a tick every 200,000,000 ms on epoch milliseconds, which lands at 14:53:20 on an arbitrary Tuesday. Time is not decimal below the year, so
internal/time-scale.tssteps in seconds, minutes, hours, days, weeks, months and years, walks the ticks with real calendar arithmetic — a month step lands on the first whatever the month's length, a day step survives the clocks going back — and aligns them, so a three-hour axis reads 00:00, 03:00, 06:00 rather than 01:00, 04:00, 07:00.The step is the one nearest the requested tick count rather than the first that is big enough, chosen by the ratio because the table spans nine orders of magnitude. Over a fortnight, "at least two and a third days" is a week — two ticks on the whole axis, which is what the first version drew.
No legend: the rows are the category axis and are already named down the side, so
legendisOmitted from the props rather than accepted and ignored. The pointer is measured to a mark's body rather than its centre, so anywhere along a bar counts — a span can be two hundred pixels whose centre a pointer never goes near. The arrow keys walk the spans, not the rows.Both ends of a span are rounded, which needed a new
BarEnd: a bar grows from a baseline and has one data end worth softening, and a span has no baseline at all. The first attempt drew it as a left-rounded path unioned with a right-rounded one, which is a rectangle with four square corners.The frame gained the two things a chart whose marks are not a grid needs: a caller-supplied
scale, and a caller-suppliedtable. A timeline's rows hold a variable number of spans, so a column per span would give the table as many columns as its busiest row; each row's cell writes its spans out instead. -
MPHeatmapChart, and a sequential ramp to colour it with. The eight chart slots are an identity channel — they say which series, and nobody can tell whether slot 6 is more than slot 3 — so a cell that has to say how much needed a different kind of colour entirely.Five steps on slot one's hue, fitted the same way the categorical palette was and validated against the skill's ordinal checks: monotone lightness, adjacent steps at least 0.06 apart, one hue throughout, and the step nearest the page clearing 2:1 against it. Both schemes pass all four. That last check is what put the light end at 0.755 rather than somewhere paler, and the dark end at 0.405: the lowest cell of a heatmap has to be a reading rather than a hole.
The scheme flips the anchor, not the hue. Pale to deep on a light page, deep to bright on a dark one, because "more" has to be further from the page in both — a ramp that ran the same way in the dark would put its high end where the eye reads nothing.
Steps rather than a gradient, and the documentation says why: given a smooth fill a reader can say "darker" and nothing else, where five bands let them match a cell against the legend and come away with a number. The legend is therefore the ramp itself rather than a key.
A
nullcell is an empty outline, not the lowest step. The bottom of a ramp is a reading, and painting "no data" as "the least" is the bridged line's mistake with the added problem that here it looks exactly like a real quiet hour. The cell under the pointer is ringed rather than recoloured, because a cell that changed colour on hover would be reporting a different number at the one moment it is being read.It also sidesteps the cap
MPScatterChartran into from the other side: every cell touches its neighbours, and colour separates only three touching things — but a ramp is not being asked to separate anything, only to be ordered. -
MPGaugeChart, one reading on a dial, with the trouble drawn on the face.It is a meter in the markup, not a picture with a label —
role="meter"carrying the value, the minimum and the maximum, exactly asMPMeterdoes, because they are the same quantity in two shapes. That is also why it has no table behind it and no hover layer: a chart of one number has nothing a reader could uncover that the figure in the middle is not already saying.thresholdColormoved out ofMPMeterintointernal/threshold.tsso both read it. Two copies of a six-line scan would agree until the day one was edited alone, and where the amber starts is exactly the thing a dial and a bar on one page must not disagree about. Its own file rather than a section ofinternal/chart.ts, because a bar is not a chart and should not be handed the scales and the path builders for six lines.bandspaints the thresholds onto the track as well as into the reading, which is the argument for this shape over a bar: "how close is this to trouble" gets answered by where the reading stops rather than by arithmetic. The documentation says so, and says plainly that a gauge without thresholds is a statistic with a decoration round it.How far the arc hangs below its centre is computed from the sweep rather than assumed, so a 240° dial does not float in its box and a 300° one is not clipped; past 300 the two ends meet and the reader cannot tell start from finish, so that is where
sweepstops. Both ends carry a tick, because without them a full dial and a dial with no scale look the same. -
MPScatterChart, and the second value axis the frame did not have. Until now the category axis was either a row of columns or a set of evenly spaced ticks;xScale="value"makes it a scale of its own, so a point'sxis a position rather than a column heading and two marks at the same index in two series are the unrelated observations they actually are.Neither axis starts at zero. A position claims nothing about proportion, so cropping both scales moves every mark by the same amount and the relationship survives; forced to zero, two measures living between 40 and 60 end up in one corner.
Three series is the cap on colour alone, so the marks take shapes too. On a line or a bar only neighbours meet, and the palette's adjacent pairs were fitted for that. On a scatter any two marks can sit side by side, and eight slots are separable pairwise only to the third — a property of the colour space, not of this palette. The five shapes are each scaled to the same area, because a square inscribed like a circle carries a third more ink and a triangle half as much, and a series that looked heavier for no reason in the data would be the second lie fixing the first. The legend swatch is the shape rather than a dot.
The grid runs both ways here and nowhere else: there is no column to be in, and reading a mark's x off the picture is half of what the reader came for. For the same reason there is no crosshair — it would be a line through one dot.
Bubbles are sized by area rather than radius. Area goes as the square, so the naive version shows four times the ink for twice the number, and the smallest bubble is a floor rather than nothing because a mark of no radius is a point that has been deleted.
There is no trend line and there will not be one. A fitted line is a claim about which model over what range with what confidence, and a component that drew one would be putting that claim in the reader's head with no way to check it.
One frame fix came with it: a mark's hover panel is now titled with the mark's own x rather than with the label shared at its index. That was wrong for every chart that supplies marks, not only this one.
-
MPPieChart, and the split that made it possible. A pie has no axes, no scales and no columns to hit-test, so reaching into the cartesian frame for a legend would have carried all three; the legend, the hover panel, the spoken readout, the table and the arrangement holding them now live ininternal/ChartChrome.tsx, and the frame is one more consumer rather than the owner. The split stylesheet is cut along the import graph a file at a time, which is what makes that a size question and not a taste one.It takes
dataand notseries, because that is what a pie is: the slices are the entities, so each takes a palette slot of its own and the legend lists them. That also makes the legend do more work here than anywhere else — a slice has no axis to be read against, so the swatch and the name are the whole identity channel.A negative is left out. It has no share of a whole, and both ways of forcing one in are wrong: as an absolute it claims the opposite of what it means, and as a signed sweep it runs backwards over the slice beside it. The table still has the number.
A single-slice pie is drawn as two arcs. An SVG arc is defined by its endpoints, so a sweep of exactly 360° starts and ends at the same point and renders as nothing at all — a chart of one category would have come out blank.
The gap between slices is a stroke in the page's own colour rather than a narrowed sweep, because a slice shortened to make room reports a smaller number than it has. Labels are shares rather than values, since a share is what a pie is a picture of, and one that will not fit its slice is dropped rather than clipped or spilled onto its neighbour.
The documentation says when not to use it, at length. An angle is a poor thing to compare, two slices within a few percent of each other are indistinguishable, and past about six slices the answer is a bar chart.
-
MPBarChart, grouped or stacked, upright or on its side. The third consumer of the frame, and the first to use the orientation the frame has carried since it was written.Its axis starts at zero and there is no prop to stop it. A bar's length is proportional to its value only from a zero baseline, and a cropped one makes a bar twice the height of its neighbour stand for a value five percent larger — in the shape a reader trusts most.
yAxis={{ min }}still moves the scale, because a caller who says so has said so; it is simply the wrong tool, and the right one is a line chart, whose marks claim nothing about proportion.Rounded at the data end and square at the baseline, with which end that is coming from the sign and the orientation together: a negative bar hangs below the axis and a horizontal one grows sideways, and in both the corners to soften are the ones furthest from zero. An inner segment of a stack is rounded nowhere — both of its faces are boundaries between shares rather than the end of anything, which is now a fifth value of
barPath'sendrather than a cast.Hiding a series widens the survivors instead of leaving a hole in every category, because the band is divided between the series that are actually drawn. Bars have a maximum thickness so that two categories in a wide chart are not two slabs half the plot across.
barPathwas rewritten to round any two corners rather than only the top or bottom pair, which is what the sideways case needed; the sparkline's one call site now names its end instead of passing a boolean. -
MPAreaChart, the first chart to arrive against the frame rather than beside it — and the proof that the frame fits something other than what it was written for. Its whole implementation is the two edges of a band and how they stack.Its axis starts at zero and the line chart's does not, which is the one place the two have to disagree. A filled area's size encodes the quantity, so the fill is proportional to the value only from a zero baseline; a line encodes position and claims nothing about proportion. Data that wants a cropped axis wants a line.
The two pixels between stacked bands come out of the band above the boundary. Every band's top edge stays exactly where its cumulative total puts it, because that edge is the data — taking the gap from below would move a value to make room for a separator. Positives stack up and negatives stack down, so a category holding +8 and −3 is two runs from the baseline eleven units apart rather than one band that has crossed itself, and a
nullcontributes nothing rather than resetting the running total.Only visible series are in the stack, so hiding one re-stacks the survivors instead of leaving a hole; the colours stay where they were, since a slot comes from a series' place in the array. Bands are faint where they overlap and nearly solid where they stack, which is the same rule stated twice: a fill is only as opaque as the thing behind it allows.
areaPathis nowbandPathwith a flat second edge. A ribbon between two runs is the general shape, a gap in either edge is a gap in the band, and both edges take the same curve — a smoothed top over a straight bottom disagrees with itself about where the band is between two points. -
MPLineChart, and the frame the other charts are going to arrive against: two axes, a value scale, a band scale, a grid, a legend, a crosshair, a hover panel and the table behind the picture. Written with one consumer, because a frame written for nobody is a frame that fits nobody.The axis does not start at zero, and that is the one place this chart and the bar chart have to disagree. A bar's length means its value, so a bar chart cropped above zero lies about proportion. A line's position means its value and nothing about the mark claims proportionality, so cropping moves every point by the same amount and the picture survives — while forcing zero onto a series that runs between 3,200 and 3,400 draws a flat line, which reports a real change as nothing happening.
yAxis={{ min: 0 }}where zero genuinely is the baseline.The scale rounds outward to 1-2-5-10, so a maximum of 4,830 becomes 5,000 and the tallest mark stops short of the ceiling; a plot whose last point touches the frame reads as clipped even when it is exactly right. Where both ends are pinned the ends cannot move, so the step gives instead, searched across a family widened by a half step until one divides the range exactly — on an axis pinned to 99.5 and 100, the alternative is leaving 100 off it.
The hit target is the whole column, not the line. A two-pixel stroke is not something a pointer can be asked to land on. Everything the pointer does the keyboard does too: the plot is one tab stop, the arrows walk the columns,
HomeandEndreach the ends, andEscapeclears the reading. The live region announcing each column is a sibling of the plot rather than a child of it —role="img"is a leaf role, so everything inside it is cut out of the accessibility tree and a live region in there announces to nobody.Every chart renders its data as a table, clipped rather than drawn, and points
aria-describedbyat it. A reader who cannot see the plot gets the numbers themselves rather than a summary; a reader who can is not made to scroll past a table they did not ask for.Labels are cut to their slot rather than dropped — five categories called "Onboarding flow" would otherwise leave one label on the axis, and an axis with one label is not a shorter axis but an unlabelled one. Ticks are never cut, because half of
12.4Kis not a smaller number but a wrong one. Both axes keep their last label when it fits and drop it when it would overlap, which is measured rather than assumed: forcing it turns a missing "Jun" into "MayJun".Written values and legend names wear ordinary ink, never the series colour. A number in the mark's colour is one the reader decodes before they read it, and it fails outright in
forced-colors.markers="auto"drops the dots once the line is denser than they are, keeping only the active column's — a crosshair with nothing on it leaves the reader to work out which line it crossed.The chart brings no surface of its own. It draws a figure, not a card, so a dashboard's panels stay the caller's decision.
New in the message table: a
chartnamespace of three strings, filled in for the eighteen locales. New intypes.ts:MPChartSeries,MPChartPoint,MPChartDatum,MPChartCategory,MPChartAxis,MPChartLegend,MPChartTooltipand the three unions beside them, which the remaining seven charts will take unchanged. -
MPSparkline, a series as a mark, in the space of a word. Three shapes, three curves, and nothing else: no axes, no legend, and nothing to read a value against. It answers which way this has been going.It has no hover layer, and that is the deviation worth stating. Every other chart here ships one, because a chart is a thing a reader interrogates — but this is thirty pixels tall, has no axis to read a value against, and a floating card over a mark that size covers the mark. What it has instead is a sentence: the accessible name gives the point count and the two ends, which is where the direction lives. "120 to 400" says which way it went; "90 to 410" does not.
A
nullbreaks the line rather than being joined across. A straight line through a value nobody has is the one kind of invented data a reader would never guess was invented, because it looks exactly like the rest of the line — and an area closed over the same gap paints the lie across more of the picture. A lone point between two gaps is drawn as a dot, having no line to be part of.smoothis a monotone fit, so a rising run never turns back on its way up and the lowest number in the data is the lowest point on screen; an ordinary spline overshoots both, and a chart that draws a dip has reported one.stepturns halfway between two points, because turning at either end draws one of the two values as lasting longer than it did.The shape is drawn in a fixed viewBox and stretched to fit — eight points and a hundred fill the same width, since a mark that narrowed as its series grew would change meaning with its own length — with
non-scaling-strokekeeping two pixels two pixels inside it. The end dot is a zero-length round-capped path rather than a<circle>for the same reason: a radius is in viewBox units, and in a stretched box it comes out an ellipse as wide as the element.New in the message table: a
sparklinenamespace of one string, filled in for the eighteen locales. -
The chart palette, and
MPStatistic, which is the first of the ten chart components and the only one with no plot in it.The palette was going to be derived from
--mp-source-colorand it is not, because the measurement said no. Rotating eight hues off the seed is the obvious answer — it themes for free and it is one line per slot. Colour-vision deficiency collapses the red-green axis at absolute hues, though, so a ramp whose adjacent pairs are clear of that axis puts a different pair straight onto it the moment the whole thing is rotated: fitted to the default seed and re-checked against five others, the rotated ramp failed the protan/deutan separation for four of the five. So the hues are fixed and only the lightness follows the scheme. Slot one is the default seed's own blue, which is why an untouched theme looks of a piece, and that is where the resemblance ends.The eight were fitted rather than chosen — hues at least 30° apart, chroma 0.15 browser-clamped per hue, and a lightness per slot per scheme, searched until all of it held in both schemes: lightness inside the readable band, chroma above the grey floor, every adjacent pair at ΔE 8 or better under protanopia and deuteranopia (the fit clears 16.2 light, 13.7 dark), every adjacent pair at ΔE 15 or better under ordinary vision (26.5 and 20.6), and 3:1 against the surface for all eight.
--mp-chart-3overrides one, exactly as a colour role is overridden.Three is the cap where any two marks can touch — a scatter, a bubble chart, a heatmap. Eight slots are pairwise-separable only to the third, and no ordering of eight does better; that is a property of the colour space rather than of this palette.
MPStatisticitself is the chart you draw when there is nothing to plot. A single number has no shape, no order and no second dimension, and putting one on a pair of axes is the most common way a dashboard wastes a panel.Four digits stay and five do not:
1,284is taken in at a glance and1.3Kthrew two of them away to save two characters, while at12,900the digits have stopped being read and started being counted. Aformatturns compacting off on its own.betterWhenis a prop and not an assumption. Churn, latency, cost, error rate and open incidents are all better when they fall, and a component that painted every fall red would be wrong about four tiles in eight — wrong confidently, in the one colour a reader trusts without checking. Flat is a third state rather than a quiet rise, and a move from zero is written as an absolute, because something that was nothing has not grown by an amount and every percentage that could go there is one a reader would believe.The arrow is the second channel, so the tile still reads in grayscale and in
forced-colors; it staysaria-hiddenwith no sentence behind it, because the number beside it already carries a sign. And the figure itself ison-surfacein every state — only the move is coloured, since a reported value that changes colour with its own trend is one the reader has to decode before reading. -
MPTour, a guided walk over a page that already exists: a few steps, each anchored to something on screen, with the dimming cut away around it.The dimming never takes the pointer, and that is the whole difference between a tour and a sequence of dialogs. A reader can use the control being pointed at while the card is up, so everything they learn they learn by doing rather than by reading that it can be done. Nothing outside the card is blocked, no focus is trapped, and neither a press outside nor the focus leaving ends the tour — only Escape, the ×, and the card's own buttons. It does not blur the page either, where
MPOverlaydoes: a 2px blur says "you cannot use this right now", and a tour is saying the opposite.The hole is one element carrying a shadow larger than any screen, not four rectangles around the target. Four rectangles never quite meet at the corners, and the seams show as hairlines across the page the moment the dimming is anything but opaque — which MD3's scrim, at 32%, is not.
Steps are given by selector rather than by element or ref, because what a tour is about is already on the page: a step can point at something another part of the application renders, or at a route that had not loaded when the tour was written. A step with no target is centred over the page with nothing cut out, which is what a welcome step and a closing step are.
Nothing in the platform reports that an element has moved. A
ResizeObserverfires when it changes size;scrollandresizefire when the window does. A banner loading above the target moves it and says nothing, and the hole is left beside the thing it was pointing at. So the tour reads the target's position every frame while it runs, which is the answer a positioning library gives to the same question — with the rect compared before any state is set, so a page holding still costs a read and nothing else. It is bounded by what makes it acceptable: a tour is up for seconds, one at a time, over a page the reader is looking at rather than working in.onFinishfires on the last step's button and nowhere else. Skip, Escape and the × all close the tour without it, which is what keeps an onboarding flow that writes "seen" on finish from marking itself complete for somebody who left.New in the message table: a
tournamespace of five strings — the four buttons and the counter — filled in for the eighteen locales. The × iscommon's, because it is the same × every sheet in the library closes with. -
MPDataTable, a grid of data with the four things a reader does to one: put it in order, cut it down, step through it and take some of it away — plus a file to keep. Sorting, a search, pages, resizable columns, row selection and a CSV download.It is a separate component from
MPTablerather than a mode of it.MPTableis the presentational half and should stay that way: a page that only shows a grid has no business shipping a comparator, a CSV writer and a page clamp to do it. What the two share is the geometry —internal/table.tsnow holds the cells' room, the type they are set in and the slot a row's background is read from — so a data table never sits a pixel off the plain table beside it in the same form. The arithmetic went tointernal/data-table.tsfor the reasoninternal/mockup.tsxgives: a component whose interesting fifty lines are buried in machinery is a component nobody can read.The rows are searched, then filtered, then sorted, then paged, which is the only order in which each step means what it says. Sorting before searching orders rows nobody will see; paging before either cuts a page out of a list the reader has not asked for yet.
Three sort states per column rather than two, because the order the rows arrived in is a state nobody can get back to by pressing anything if the cycle has only two. The direction is handed to the comparison rather than applied to what it returns, and that is load-bearing: an empty cell sorts last whichever way the column runs, and a sort that reversed the result would have reversed the empties with it. A run of digits inside a string reads as a number, so
item2comes beforeitem10.A row's searchable values are folded once and joined into one string, on a character no keyboard produces — so a query cannot span the seam between two cells and find a row on text that is not next to itself. The alternative is a
toLocaleLowerCaseon every cell of every row on every keystroke, which is the shape of API amatches(row, query)signature quietly asks for.resizablechanges how the columns are laid out, and it has to. Without it the browser balances the columns against their contents, which is what makes a plain table read well; a balanced column springs back from a drag, because a width in that layout is a hint rather than a measurement. The handle is a control rather than a target — it takes focus, the arrow keys move it, and Home puts the column back — because column width is a real preference and there is no other way to get it.A row that answers a press has to answer a keyboard, and two hundred rows that each did would be two hundred tab stops. So there is exactly one route into a row and
checkboxespicks it: with ticks, the tick is the control; without them, the row is.The download writes every row the reader is looking at, not the page they are on — the search and the sort applied, the paging not, because a file of page three is not a file anybody asked for. RFC 4180 escaping, and a byte-order mark in front: Excel reads a UTF-8 CSV without one as the local code page, so every non-ASCII name in it arrives as mojibake.
What it deliberately does not do: virtual scrolling, cell editing, grouped rows with aggregates, column reordering, frozen columns, and a server-side mode. Each is a component's worth of decisions rather than a prop, and a table that half-did six of them would be worse than one that does five things completely.
New in the message table: a
dataTablenamespace of seven strings, filled in for the eighteen locales. Its two counts read as labels — "Rows: 7" — in every language that inflects a noun for number, because{total} rowssays "1 rows" one time in seven and the alternative is a plural form per language in a table that holds one string per key. -
MPTreeSelect, a value chosen from a tree rather than from a list — the gap betweenMPSelect, which is a flat list behind a field, andMPTreeView, which is a hierarchy with no field. A category, a folder, an org-chart node and a region all live somewhere, and a flat list flattens the somewhere away.Branches are not answers by default. The branches are the taxonomy and the leaves are the answers, and a "Europe" that can be chosen alongside "France" is usually a data model nobody meant;
selectableBranchesis how the day it was meant gets said, and an item's ownselectableoverrules the rule either way.A branch that cannot be chosen is deliberately not a disabled row. It looks like every other row, takes focus, and opens and shuts — a taxonomy whose branches do not open is a list with extra steps, which is what a tree was chosen over. Its press is answered before the tree sees it rather than filtered out of what the tree reports, because the tree reports a press in single-value mode as the whole new selection: filtered,
['europe']would arrive as an empty one and a press on a heading would clear the field and shut the popup on the way past.A search keeps the ancestors of a match. Filtered to bare matches a tree is a list, and "Seoul" under nothing at all does not say which Seoul. Two rules follow from the same reasoning: a node that matches keeps all of its children, because having found the branch the reader asked for, hiding what is inside it is the opposite of helpful; and every branch the filter kept is opened, because a match folded inside a shut parent is a match the reader was not shown. The field's placeholder is the "Search"
MPTransferalready writes down — the same field doing the same job — rather than a nineteenth translation of the same word.It is built on the shell the four date and time pickers wear, so the trigger is an
MPTextField's notched outline to the pixel — and it is the first of them with no glyph of its own, which is what turned up the label bug below. -
MPTreeViewandMPTreeItem, a tree of rows that open and shut. There is no Base UI primitive under it — the library has no tree — so the three things the pattern owes are most of the component: thetree/treeitem/grouproles, one tab stop for the whole widget, and the arrow keys that walk it.The keyboard is handled once, at the top, because a tree's arrow keys are questions about the tree — what is the next visible row, where is my parent — and the only element that can answer them is the one holding all of them. Right and Left swap under RTL, read off the element rather than off a prop: a caller may have set
dirthree ancestors up.The disclosure arrow is a span rather than a button, and that follows from the ARIA pattern: the
<li>is thetreeitemand it is what takes focus, so a button inside it would be a second tab stop in a widget that is supposed to have exactly one. What the arrow does is the interesting part — it opens the branch without choosing the row, which is the difference between pointing at a folder and opening one.A shut branch is unmounted rather than hidden, which is what keeps the order the arrow keys walk the same as the document order. A branch that is shutting is a third state: it has to stay in the document while it collapses, and for that moment its rows are deliberately not part of the tree — pressing Down on a folder you just closed goes to the row after it rather than back into it.
The row height is the one place in the library where a row leaves the control ladder. A field is 56dp at
mdbecause MD3 draws one at 56dp; a tree row is one line in a column of dozens, and six of those at 56 is a sidebar that shows six things. The trade —mdat 40 is under the 48dp touch target, so a thumb wantslg— is stated rather than hidden. -
MPMockup, a picture of a device with a real page inside it. Three devices, six systems, four bezels, three finishes and four camera cut-outs, and the point of all of it is the screen:childrenare laid out against the machine's own resolution — 390 by 844 for a phone atmd— and the whole device is then scaled once to whatever room it was given. A layout inside wraps where it would wrap on the machine rather than where it would wrap in the box the picture fits in.That scale is a
transform, and it is the one place in this library that uses one. The rule it is an exception to is about controls: a scaled button is a button whose text was resampled at the moment it was pressed. Nothing here is pressed, and the scale changes only when the room does. What is not an exception is the media query —@media (max-width: 600px)inside a phone mockup on a laptop is false and always will be, because nothing on a page can change what the window is. Content that has to respond inside the frame wants a container query, which the documentation says.sizesets neither a height nor a type scale here, as onMPBox. It picks between five real resolutions per device, because the number that matters is the one a media query fires at: a layout checked at 390 and 430 has been checked at the two widths most phones actually are.Hardware is a photograph and software is a page. The finishes are fixed colours and do not follow the theme — a graphite phone stays graphite on a page switched to dark — while the chrome on the screen is drawn from
surfaceandon-surface, so the frame stays a frame around your page rather than a picture of somebody else's screenshot. The exception inside the exception is macOS's three window lights, which are a shape rather than a surface.Every system bar takes its own room rather than covering the content, so
systemUi={false}gives the screen back tochildrenrather than uncovering anything. The cut-out is the other way round: it is hardware, drawn whether or not the chrome is, because a phone does not stop having a camera because the status bar was turned off. -
MPFloatingBottomNavigation, the same row of destinations asMPBottomNavigation, lifted clear of the bottom edge so the content keeps going underneath. The same<nav>, the sameMPBottomNavigationItemchildren, the samearia-current="page"and the same refusal ofrole="tablist".Everything about the shape follows from
offset. Because the page runs below it the sheet is a stadium rather than a bar with two corners; it is only as wide as its destinations, so those are as wide as their own contents rather than a share of the screen; it carries a shadow by default, because a lozenge lying flat over what it floats above reads as a mistake; and it names only the destination the reader is on, since five drawn names would stretch it back into a bar.There is no second highlight. A floating bar could have one tile that slides between destinations; this deliberately keeps the pill each item already wears, because the specification draws one selected treatment and a lozenge with both would say where the reader is twice, on two different curves.
safeAreamoves the whole sheet here rather than the row inside it, which is what the full-width bar has to do: there is nothing under a floating bar that has to stay covered. -
The bottom navigation context moved to
internal/bottom-navigation.ts, so anMPBottomNavigationItemworks in either bar without knowing which it is in. The import would otherwise have run the wrong way — a floating bar reaching into the full-width bar for a context would carry its height table and position map into every page that rendered either. -
MPHoverCard, a card that opens when the pointer rests on something and holds a preview of what is on the other side of it. It fills the gap between the two popups already here: a tooltip is a label the pointer never reaches, a popover is a panel that was asked for by a press, and this one is uninvited like the first and reachable like the second — the pointer can cross into it, and a link inside it can be followed.Because it is uninvited it is never the only way to anything. A keyboard with no hover, a touchscreen with no pointer and a screen reader all arrive by the trigger's own route, so whatever is in the card has to exist on the page the trigger leads to as well. There is deliberately no keyboard equivalent bolted on: a card that opened on focus would interrupt every keyboard reader tabbing through a paragraph of links.
titleanddescriptionare wired toaria-labelledbyandaria-describedbyby hand, because Base UI's preview card has noTitleorDescriptionpart the way its popover does — without them the card is a sheet a screen reader reads cold.No
variant, nocolorand noelevation, for the reasonsMPPopovergives. -
MPToolbar, a bar of controls: an application header, a page's action row, the strip along the bottom of an editor.startandendare pinned to their ends andchildrentakes what is left — the arrangement every toolbar has ever had, laid out here rather than left to a caller and the spacer<div>they have to remember. The middle staysflex-1even when empty, or a bar with only a logo and a button collapses the two together in the centre.It takes no height. A toolbar is as tall as the controls in it plus its padding, and that padding is the
size/densitypair every other surface uses, so the dense bar isdensity={-2}rather than a second prop meaning the same thing.It has no
role="toolbar", deliberately. That role is a promise about keyboard behaviour — one tab stop for the bar, arrow keys between the controls in it — and a bar that claims it without implementing it is worse for a keyboard reader than one that claimed nothing. A page header wantsrender={<header />}, which is a landmark and is true; a genuine roving-focus toolbar wantsMPButtonGroup, which is one.elevationis unset by default, so the bar is flat even when it is pinned: a shadow under a header says "there is content beneath this", which is only true once the page has been scrolled. A pinned bar also drops its corners, because a rounded corner against the edge of the screen is a gap with nothing behind it. -
MPCodeBlock, a viewer for one line of code or a thousand. Coloured, numbered, marked, copied, wrapped, scrolled — each off one prop, because the same component has to be a snippet inside a sentence and the full transcript at the top of a README, and those are one block with different things turned on rather than two components.This adds
highlight.jsas the package's second runtime dependency (BSD-3-Clause), and it is the only one that is loaded rather than imported. Every grammar sits behind animport(): a block withhighlight={false}fetches nothing at all, a block that colours TypeScript fetches the core and TypeScript and not the other thirty-three, and a page with no code block downloads none of it.registerMPLanguageis how a thirty-fifth language arrives, on the same arrangementregisterMPMessagesmakes for the eighteen locales.It is drawn as lines even with no numbers and no prompt, because a line is what carries a number, a prompt and a place in the scroll. The numbers and the prompt are generated content, so they cannot be selected, cannot be found by find-in-page, and are not what the copy button puts on the clipboard: a transcript stays a transcript and still pastes into a shell.
The block does not take the page's accent, and that is a decision. Code is read against a ground chosen for code, and the twelve hues that keep a keyword apart from a string apart from a number are not the four families MD3 has. Four themes ship —
auto(the default, following the page's scheme the way every other surface here does),dark,lightandmono— andthemetakes any string, so a set of--mp-code-*properties under a[data-mp-code-theme]selector in your own CSS is a theme. No third-party palettes are ported: One Dark, Dracula, Nord and the rest are other projects' published work under their own licences. -
npm run measurereports what is deferred, because one component now defers part of itself. Each scenario is bundled twice — the second time withhighlight.jsexternal — and the columns report the smaller figure, which is what a page loads before it has drawn anything; the difference is printed beside it rather than dropped.splitting: truewas the obvious way to measure the same thing and is the wrong one: esbuild emits a chunk for everyimport()it parsed, before tree shaking has decided the module is unreachable, so animport { MPButton }came out carrying thirty-six grammar chunks that no real build emits. -
MPDataListandMPDataListItem, a list of things and what they are called: a details panel, the summary of a record, the metadata under a heading.Not a two-column
MPTable, and the difference is the whole reason it exists. A table is a grid of rows all of the same shape, and a screen reader walks it as one — announcing column headers, offering cell-by-cell navigation, counting rows. That is right for a set of records and wrong for one record's fields, where the column headers would be Field and Value and neither says anything. This is a set of pairs, each announced as "label, value", which is what a details panel actually is. So it is a real<dl>of real<dt>/<dd>pairs.MPDataListItemrenders a fragment rather than a wrapper, because the two elements have to be direct children of the<dl>for the grid to line every label up against every other. It carries nosizeordensityof its own either — those belong to the list.dividersbehaves differently in the two orientations, and has to. A horizontal pair shares a grid row, so the hairline goes on both halves to reach across; a vertical pair is two rows, and a line on the<dd>would divide a label from its own value — the one place in the list with nothing to divide.The gaps are custom properties rather than the literal class tables the rest of the library keeps, and this is the one component where that is the smaller answer: both gaps are read in four places across five rungs and four density steps, which written out is sixty strings for two numbers that then have to agree with each other by hand. It also makes the density step exact — 4dp off the gap, because a row here has no height of its own and the gap is the whole of what there is to take.
-
MPAppLogo, a product's mark at a known size that is never an empty box. Four things can be it and exactly one is at a time: markup handed tochildren, an image atsrc, the initials ofnameon a tile, or — with no tile to put them on — the name itself set as the logotype. That last one is the point:<MPAppLogo name="Voltage" />is a logo for a product that has not drawn one yet, and swapping it for the real file later is one prop.What it adds over an
<img>is the framing, which is the decision an image tag cannot make: a mark drawn as a bare glyph and a mark drawn with its own background need opposite treatment, and which of the two a file is cannot be worked out from the file.barekeeps the artwork's own proportions and draws nothing behind it — the default, because a logo file very often has the product's name set into it — whileappandcircleinset it into a tile of the page's own colour.The inset is a share of the artwork rather than padding on the tile, and that is not a stylistic preference: a percentage padding resolves against the containing block, which here is the whole lockup, so the same icon would be inset further the longer the product's name beside it got.
The name lands in the document exactly once, and which element carries it depends on what the mark turned out to be — a logotype is the name, an image carries it as
alt, and a glyph or a pair of initials get a clipped copy.showNameturns the mark into decoration rather than a second reading of the same thing.No tagline, no version, and no
elevation: that prop moves tone as well as shadow everywhere else it appears, and a logo tile's tone is the accent, so there is nothing for it to move to. -
initialsOfmoved tointernal/initials.ts, out ofMPAvatar.MPAppLogoderives a product's initials the way the avatar derives a person's, and two copies of that rule is how a page carrying both ends up disagreeing with itself about what a two-word name comes to. -
MPMeter, how much of something there is on a scale known in advance — disk used, seats taken, quota spent, a password's strength. It looks likeMPProgressLinearand means something else.A progress bar is about time: something is happening, this is how far it has got, it may have no value at all, and it is expected to move on its own. A meter is about quantity: the number is already known, it does not move unless the thing it measures does, and it is meaningful to say the reading is bad. A disk that is 94% full is not 94% finished, and the two carry different ARIA roles for exactly that reason — so
valueis required here and optional there.thresholdsis where the bar changes colour, and it names roles rather than colours. MD3 has four accent families and none of them iswarning, so an amber band istertiaryunder whatever source colour the page is themed from; a threshold that named a colour would be a colour that ignores the theme, on the one component whose whole point is to be read at a glance.fromis a value on the meter's own scale rather than a percentage, and the list is read in the order it was given rather than sorted, so a call site that listed the bands the wrong way round shows it.A new
MPThresholdin the shared vocabulary rather than a type inside the component, because more than one shape reports the same reading: a meter and a gauge must not disagree about where the amber starts. -
MPAnchor, the headings of the page being read with the one the reader is in marked. A<nav>of real fragment links, so the rows jump to their headings with JavaScript turned off and are in the link list a screen reader can pull up; watching the scroll is added on top rather than being what holds it up.The marked row carries
aria-current="location"rather than"true". That value means "where the reader is within a set of links", which is what a table of contents reports;"true"is the current page, which is what a navigation menu's own entry says.itemsis given rather than scraped. Whatever produced the page knows its own headings and the ids on them, and a component that went looking for<h2>s would be guessing at which of them were content and which were chrome.Two things make the wrong row light up, and both have a prop: a sticky header (
offset) and a scroller that is not the document (container). The rule is the last heading whose top has passed the line — the only one that reads correctly while scrolling up as well as down — with two deliberate consequences. Nothing is marked at the top of a page, because the reader has not reached the first heading. And the last heading is marked at the bottom whatever the measurement says, because its section usually has less under it than a viewport and its top never reaches the line at all.The rail's lit segment is a border on the row, never a marker that travels between rows: a thing moving under a reader who is already moving is the one animation a table of contents should not have.
-
MPScrollZone, a strip of anything laid out in one direction and scrolled in it. Cards, chips, avatars or thumbnails run across the box or down it, in as many lines as you ask for, with a pair of buttons for the pointer that has neither a wheel nor a finger.The mechanism is an ordinary scroll container and everything the component offers is a way of driving one. Swiping, two-finger dragging, the arrow keys and the scrollbar are the browser's own and none of them is intercepted; nothing is transformed, which is what makes the strip run the other way under RTL without being told.
linesis what separates it fromMPCarousel. A carousel is one thing at a time and knows which one — it has avalue, an index and a row of marks. This has none of those, because it is a shelf that happens to be longer than the room it is in, and two rows of thumbnails is a shape a carousel cannot hold. The track is a grid rather than a flex row for the same reason: a wrapping row wraps at the box's edge, and what is wanted is a fixed number of rows and as many columns as it takes.buttons="auto"does two different things at an end, and the difference is what the absence would cost. An inline button sits in a lane that is held open either way — a lane that came and went would resize the strip under the pointer that had just reached the end of it — so it stays and goesdisabled; an overlay button takes no room, so it is removed.The wheel stays the page's unless
wheelasks for it, and what it takes it gives back at the ends: a reader who meant to scroll past the shelf is not held by it. Dragging takes neither the pointer nor the selection until the pointer has moved past a few pixels, so a plain click on a card inside the strip is still a click.The strip is a tab stop with a name, because a reader with no pointer has no other way to move it.
holdmode answers Enter and Space held on the button as well as a pointer, which is the half a pointer-only implementation forgets. -
MPScrollArea, a box with a scrollbar of its own. The browser's is drawn by the operating system — a different width on every machine, a different colour from the sheet it is cut into, and on Windows a 17px band of grey no design accounts for.The native bar is hidden rather than the scrolling being reimplemented, which is the difference between this and a component that listens for
wheelevents: the wheel, the trackpad, a touch drag, Page Up, Home and End, a keyboard focus moving below the fold, scroll anchoring and overscroll all behave exactly as they did. The bar overlays the content and takes no room, so adding one to a page that has already been designed reflows nothing.The bound goes on the viewport rather than on the root, which is not a detail: a
max-heighton the root leaves its own heightauto, and a viewport asking forheight: 100%of anautoparent gets its content's height — a box exactly as tall as what is in it, which never scrolls.persistentis about opacity rather than about mounting, because a viewport that does not overflow has no bar in the DOM to begin with. -
MPPortal, children rendered somewhere else in the DOM — usually the end of<body>— for a subtree that has to escape a clipping or stacking context its own position would trap it in.Every popup in this library already portals itself, because a component whose whole job is to sit over the page cannot leave that to a caller. This is for a caller's own overlay in a page whose structure it does not control, and it gets out of two traps neither of which is visible from the markup: an
overflow: hiddenanywhere above an element, and the containing block atransform,filteror sub-1opacity makes for aposition: fixeddescendant.It draws nothing on a server and nothing on the first client render, which is what keeps hydration honest — and means a portal's content is never in the HTML a crawler reads.
containertakes a ref and is re-read after every render rather than only on mount, so a container this same page draws is picked up when it appears rather than being missed for good.It moves an element and does nothing else. Focus, Escape, an inert background and a scrim are
MPDialog's andMPDrawer's, and a hand-built overlay in a portal has none of them.disabledsaves the branch at a call site and not the state: changing it remounts the children, which is documented rather than left to be discovered. -
MPFlex, a row or a column and the width at which it changes from one to the other. It draws nothing — no surface, no padding, no corner — only the five properties a flex container has, each of them sayable per window size class.A row that is always a row wants a
classNameand not this. What this is for isdirection={{ compact: 'column', medium: 'row' }}: written as Tailwind's own variants that isflex-col md:flex-row, which is the library's boundary said again in Tailwind's numbers — 768px rather than 600 — and a layout that reflows at one width while theMPGridbeside it reflows at another. Themp-medium:variants this package ships are the other right answer, for a page already writing Tailwind.Every axis resolves in CSS, as
--_mp-flex-*slots and the same four-step fallbackMPGriduses, so a window crossing a boundary changes the layout with nothing re-rendering and a server-rendered first paint is already right.Each one clears the whole slot set on itself, which is what stops a nested flex inheriting the outer one's classes — an inner row told
direction="row"would otherwise resolve its parent'slargeentry above 1200dp and come out a column on a laptop. -
Four hooks:
useMPDisclosure,useMPMediaQuery,useMPElementSizeanduseMPOnScreen. The five already exported were machinery the components were running; these four are the general form of something the library does several times over and could not export as it stood.useMPDisclosureis the six lines every page writes to open a dialog, with the two details a version written in a hurry leaves out: the callbacks are stable for the life of the component, and asking to close something already closed is not a state change — which matters because a close handler is usually wired to a button, anonOpenChangeand an Escape at once.useMPMediaQueryis one query whereuseMPWindowClassis four. Not for a width: that would be a copy of a number the library also holds, and the two drift the moment anMPConfigProvidermoves a boundary.useMPElementSizeanswers what a container query asks and a media query cannot — a card in a sidebar and the same card in a main column are the same window and two different widths. It reports the border box rather than the observer'scontentRect, and re-renders only when a rounded number changed.useMPOnScreenis whattrigger="visible"already does, for the other reasons to ask: loading an image, starting a request, pausing something that has scrolled away. -
classNameson the five components with a partclassNamecannot reach:MPSelectandMPCombobox(popup,item),MPDialogandMPDrawer(backdrop), andMPCommandPalette(backdrop,input,list,row).Two ways of being unreachable, and both are real. A popup, a scrim and a palette's sheet render at the end of
<body>, so no selector written against the root will ever find them. And a row drawn from anitemsarray has no element of the caller's to carry a class at all — there is nowhere to write one.There is never a
rootkey.classNameis the root on every component, and a second spelling of it is the drift the prop conventions exist to prevent. The prop is also on five components rather than on everything: every other part this library draws already carries a BEM-style class of its own —mp-card__bodyand its like — which a stylesheet of yours can select without any prop at all. -
transitionon the components that display something:MPBox,MPCard,MPAvatar,MPChip,MPTypography,MPBlockquote,MPEmptyandMPAlert. An entrance, run once as the component mounts —transition="fade", or an object carrying a duration, an edge to come from or a scale to start at.It has no
trigger, norepeatand no scroll timeline, and the absence is the design rather than an omission. Those are what theMPAnimate*components are, and a prop offering half of them would be a second, worse spelling of machinery that already exists. The prop is for the two cases where wrapping is the wrong shape: a component that lays its children into boxes nothing outside can reach — which is whyMPStackhas had this since it arrived — and a grid whose every card would otherwise gain a second element, since adisplay: contentswrapper generates no box to animate.It costs about 1.2 kB gzipped on any component that takes it, passed or not, because the effect tables are object literals and a bundler cannot tree-shake a key.
MPBoxgoes from 0.7 kB to 1.7. That is the whole reason the prop is on eight components rather than on everything, and why none of the controls have it: a button arriving with a flourish is a page's decision about a region, made with a wrapper.Every default matches the wrapper's, so
transition="zoom"and<MPAnimateZoom>are one effect said two ways rather than two effects that look similar. -
elevationon the sheets that can be raised, on MD3's five levels:MPBox,MPCard,MPAccordion,MPCollapsible,MPList,MPTable,MPHeader,MPFooterandMPSidebar. The stylesheet gained levels 4 and 5 to go with the three it already had, so--mp-sys-elevation-level1throughlevel5are all defined and all themeable.It moves the tone as well as the shadow, and that is the only shape the prop could take here. MD3 does not treat height as a free axis: an elevated surface is
surface-container-lowunder a level-1 shadow, and the two are one decision. A prop that only cast a shadow would raise afilledbox into a surface the specification has no name for — a lifted object that is somehow still the flattest tone in the system. So each level names a surface role and the shadow that goes with it, exactly as Material publishes the pairing.variant="elevated"iselevation={1}, named, and stays: the variant vocabulary is about emphasis rather than about height, and it is the answer nearly every raised sheet wants. Given a level, the level decides the surface andvariantis left holding only its hairline — anoutlinedsheet keeps its border and nothing else paints. Two props writing onebackground-colorwould otherwise be settled by the order two class names reached the generated stylesheet.Levels 4 and 5 share the tones under them because the specification runs out of container roles before it runs out of levels. A bar reads the same ladder one rung higher, which is where
MPHeader'selevatedalready sat. -
densityon the components that hold things, and onMPConfigProvider. Material's own density scale —0,-1,-2,-3, four pixels a step — onMPList,MPTable,MPCard,MPBox,MPAccordion,MPAlert,MPHeader,MPFooterandMPSidebar.It is not a second size ladder, and the separation is the whole reason it is a second prop.
sizepicks which control this is: the height, the type role, the padding that follows from both.densitytakes room out of the one that was picked, and takes it out of the spacing only — the type scale does not move.size="sm"on a list is a small list;density={-2}is a normal list with more of it on the screen, which is what a dense screen is actually asking for. Shrinking the text to fit more rows makes them harder to read at exactly the moment there are more of them.Every step lands on a height the ladder already has a name for. An
mdlist row walks 56, 52, 48, 44, and 48 is whatlgat-2gives — so a dense list and the button beside it still line up, which is the promiseMPSizemakes and the one a density axis is most likely to break.A step that would take a control under 24px is not taken.
xsruns out after two and stays at 24. Refusing the value would makedensity={-3}an error on exactly the rung most likely to be given it, and honouring it would ship a control nobody can hit.Only containers take it, so
<MPConfigProvider density={-1}>tightens a product's lists, tables and cards and leaves every button, field and switch at the height a finger needs. A button is one control at one height and hassizefor that. -
MPShow, content at some window sizes and not others. A rail on a laptop and a bottom bar on a phone is two components with a boundary between them, and writing that boundary meant either a Tailwind arbitrary variant with the width in it — a copy of a number the library also holds — oruseMPWindowClassand a branch, which cannot answer until the page has hydrated.from,untilandonlyover the five window size classes.fromanduntilover the same class are exclusive and exhaustive: one of them is on screen at every width and never both.It is
display: noneand not a condition, and the trade is worth knowing rather than glossing. A media query is resolved before the browser paints anything, including the markup a server sent, so the first frame is already right — but the hidden branch is built, laid out, skipped at paint, and its effects run. That is the right way round for a nav or a column of filters and the wrong way round for a chart or a table of a thousand rows, which is where the hook earns its second render.The wrapper is
display: contentsat zero specificity, so anMPShowin a flex row makes its children the flex items rather than becoming one itself, and aclassNameof your own that sets a display wins over it without a fight. -
Eight window size class variants, for a page's own utilities:
mp-medium,mp-expanded,mp-large,mp-extra-largeand amp-below-*for each.<div className="mp-medium:flex mp-below-expanded:hidden">changes where this library changes rather than at Tailwind's own 768px, on both installation paths. There is nomp-compact— every window is at least that wide. -
breakpointsonMPConfigProvider, moving where the window size classes begin for everything the library decides in JavaScript:useMPWindowClass,MPSidebar's collapse, and the rungsmaxWidthresolves to. Partial and merged over MD3's, in CSS pixels.It does not move the stylesheet, and cannot: a media query is resolved before any of this runs and cannot name a custom property. This prop is how an application tells the JavaScript side what it already did on the CSS side; setting it alone moves half a layout, which is worse than moving none of it. See Changed for the other half, and Breakpoints for the whole of it.
-
maxWidthtakes a CSS length, and changes with the window, onMPContainer,MPHeaderandMPFooter. It was one of five rungs or nothing, and both of the things it could not say are the common case rather than an edge.A measure is often a decision about the text and not about the window — the classic answer for a column of prose is around 60 characters, which no ladder of window widths can spell — so
maxWidth="60ch",'42rem'and'min(90vw, 70ch)'all reachmax-widthuntouched. Nothing is validated: a length CSS cannot parse leaves the element unbounded, which is the answer'none'already gives and a better one than a page that renders nothing because a unit was mistyped.And a page usually wants the margin edge to edge on a phone and a measure above it, which was a
classNameundoing a prop.maxWidth={{ compact: 'none', expanded: 'lg' }}is the sameMPResponsiveshape the grid's props are, resolved in CSS. -
MPStack, things laid over each other in a pile. A stack of avatars, a deck of cards, a heap of documents, a run of overlapped thumbnails — one component, because "several of these, overlapping" is one idea and the things being overlapped are not the component's business. See Removed for the migration.The overlap is a negative margin and not a
translate, which is the detail nearly every implementation of a pile gets wrong and which is invisible until something is put next to one: with atranslateeach item still occupies its full width, so the box stays the size of all of them laid end to end and everything after the stack is positioned against a measurement that is wrong. Five 32px items at a 10px overlap come to 120×32 horizontally, 32×120 vertically and 120×72 on the diagonal — the box fits the content in all three, and those are the numbers the tests assert.diagonalis a horizontal flow with a per-item vertical margin, because a flow only overlaps on the axis it flows along and a fixedmargin-block-startin a row does not accumulate. It is not 45° and does not pretend to be: the horizontal advance is the item's own width less the overlap, and a library does not have the item's width, so the fall is a separatedropwhose default is a shallow fan.Each item is wrapped rather than cloned, so a face inside a router's link or a tooltip trigger comes through untouched. Wrapped twice: the outer span carries the offset, the
z-indexand the entrance, and the inner one carries the staticscaleandopacity— because thegrowandzoomkeyframes animate the individualscaleproperty, and a resting depth on the same element would be overwritten by the keyframe. -
data-mp-overflowon.mp-tabs__list, saying which end of a scrolled tab bar has more bar past it —left,rightorboth, and absent when everything fits. It is what the new fade at the bar's ends is drawn from, and a page that wants a different treatment reads the same fact. Physical rather than logical: it names a side of the box, on the rule the indicator's--active-tab-leftalready follows. -
MPAnimateScramble, text settling out of noise.MPAnimateTyping's sibling, and the difference is what the box does: a typed line grows a character at a time, so everything after it on the page moves while it runs — this one is its finished length from the first frame and only the characters inside it change. That is what lets it go in a heading with a rule under it, or in a table cell, where a typewriter cannot.Its waiting state is the line fully scrambled rather than the answer, and the noise holds still while it waits. Motion that starts before the trigger is not the effect, it is a distraction beside it, and a page of paragraphs quietly flickering is a page nobody can read. The characters are drawn from the index and the frame rather than from a
Math.random()in state, so a re-render cannot make them jump either. -
MPAnimateCounter, a number counting up to its value — and it is still a CSS animation. Text is not an animatable property, so a count needs a frame loop; the obvious implementation gives that loop its own clock, its own easing and its own idea of what paused means, which is three things that have to be kept in step with the six declared effects and will not be.So the animation is a real one over a registered custom property —
@property --mp-count, interpolated by@keyframes— and the loop does nothing but read the value and format it.duration,delay, the easing token,trigger,paused,prefers-reduced-motionandtimeline="view"are then the same machinery as everything else rather than a second implementation of each, and scrolling aview()counter backwards counts back down.It also means the waiting state is right for free: a paused animation with
fill-mode: bothis sitting onfrom, so a counter waiting to be scrolled to shows where it starts rather than the answer it was about to give.Formatting is
Intl.NumberFormat, so a currency or a compact notation is a prop. Concatenating a prefix and a suffix can only write one of$1,234.50and1.234,50 €. -
MPAnimateSplit, a line arriving a word or a character at a time.MPAnimateAppearfor a string: the same settling and the same stagger, over the pieces of a sentence rather than over children a caller wrote out.What it adds beyond splitting is the part that is easy to get wrong and invisible when you do. The pieces are cut with
Intl.Segmenter, so a Japanese or Thai sentence is cut into words rather than handed back whole, and👩👩👧is one character rather than seven. The whole line goes to a screen reader once out of a clipped box, because a line split into characters is otherwise announced as a list of letters. And each word is its own inline-block with the characters inside it — a piece has to beinline-blockto move at all, which also makes it a break opportunity, so loose characters would let a line wrap mid-word. -
MPAnimateFloat, an endless slow drift, andMPAnimateShake, the answer to something that did not work. Neither is an arrival, and that is what they have in common: a drift has no destination and a refusal is a reply rather than a state.So neither joins
MPAnimation. That union is backed by three lookup tables, and an object literal is not tree-shaken key by key — every component reading one pays for the rows it will never use, and a fade has no business carrying a row for a refusal.useAnimateElementtakes aneffectClassnow, so an effect can bring its own@keyframesand borrow everything else: the trigger, the rewind, the slots, the stagger.MPAnimateFloatis decoration and says so: never on a control, because a pointer arriving where a button used to be is the exact failure the library's rule against transforming controls exists to prevent. One per page — a second halves the effect and a sixth cancels it.MPAnimateShakeis the library's only exception to that rule, and the argument is that the rule is about a control's resting states, where movement stands in for something colour says better. A shake is not a state; it is a reply to what the reader just did, and it is over in four hundred milliseconds. It defaults totrigger="manual"— the one default in the set that is notmount— takes norepeat, and its keyframe is home at both ends so an interrupted shake leaves nothing displaced. -
MPAnimateReveal, an entrance that uncovers content where it already is. The only one in the set where nothing moves and no colour changes: the element is at its final position and its final ink from the first frame, and what changes is how much of it has been disclosed.That is what anything whose position is part of its meaning needs — a page title, a rule under a heading, a chart's plot area, a table's first row. A title that slid up from below was, for a moment, in the wrong place; a plot area that grew from 80% was, for a moment, showing the wrong values.
MPAnimateFadeis the other effect that leaves position alone, and the two differ in what they spend: a fade spends the colour, a reveal spends the extent.clip-path: inset()rather than a mask or anoverflowwrapper. A mask needs a gradient per direction and a second one to undo itself; a wrapper puts an element into the layout that was not there before, which inside a grid or a flex row changes what the content is a child of — the very thing the effect exists to leave alone.inset(0)is already the spelling of "nothing is clipped", so the resting appearance is the element's own.fadeis off by default, which is the one setting worth arguing about: a wipe that is also translucent reads as neither of the two things. It takesstaggerandtimelinelike the rest of the six. -
timeline="view"andrangeon the same seven, and onMPAnimateAppear. The animation is handed to the reader's scrolling instead of to a stopwatch: its progress is the element's progress through the scrollport, so scrolling back runs it backwards and stopping halfway leaves it halfway.Two declarations of CSS, and every keyframe in the set gained the mode. There is no scroll-driven component and no second set of frames, because a fade tied to a scroll position is not a different effect from a fade — it is the same one on a different clock.
Three of the props above stop meaning anything on
view:duration,delayandrepeatare the stopwatch's units. So doestrigger, and the animation is held running for it — a paused scroll-driven animation shows nothing at all, which is whattrigger="manual"with nothing pressing go would otherwise leave on the page. An explicitpausedis still honoured.rangeis what replaces them, and its default ofentry 0% cover 45%has the element finished arriving by the time it is somewhere a reader would be looking; a range running to the far edge is how most scroll-driven pages go wrong.A browser without
view()falls back to the clock and plays the effect once, exactly as before. The pair is behind an@supportsfor a sharper reason than the browsers that have none of it:animation-timelineandanimation-rangeare two properties, and taking the first without the second would give an animation a timeline and nothing to run it over. -
stagger,durationStepandreverseon the single-keyframe effects —MPAnimateFade,MPAnimateGrow,MPAnimateZoom,MPAnimateSlide,MPAnimateRotate,MPAnimateBlinkand the newMPAnimateReveal. With astaggerthe effect runs on each child rather than on the box, held back by its position, so what arrives is the set in the order it should be read.Three props rather than an
MPAnimateStaggercomponent, deliberately: a list settling in is not a different effect from a fade, it is the same fade told when to start, and a wrapper would be a second spelling of something these six already did.The box animates nothing while a
staggeris set. Eight children fading in under a box that is also fading in is the same content faded twice, and what a reader sees is one opacity multiplied by the other. The play state is the one slot the box keeps, and it reaches the children by inheritance —pausedstill holds the whole set.MPAnimateAppearwas where the per-child code was written, and it is now where the other six get it from rather than a seventh copy. Its own behaviour is unchanged, including that astaggerof0there still means a set arriving together: it has no box animation to fall back to.They are not on
MPAnimateMarquee,MPAnimateHeadline,MPAnimateTypingorMPAnimateLighting. The first three read what their children are — duplicating them, swapping between them, counting their characters — and cannot hand an arbitrary child an animation; the fourth puts its movement on a pseudo-element a child has no equivalent of. -
mp-radio__fillon the dot inside the ring, which had no name of its own. -
Eleven more class hooks, on the parts the motion work below had to reach and which had no name at all:
mp-select__mark,mp-combobox__mark,mp-menu__markandmp-menu__doton the tick or dot beside a chosen row;mp-stepper__bullet,mp-stepper__connectorandmp-stepper__label; andmp-timeline__item,mp-timeline__bullet,mp-timeline__connectorandmp-timeline__title.The same BEM convention as the other seventy, and the same trap with Tailwind's underscore — see Getting started for both ways round it.
Removed
-
MPAvatarGroup. It was a special case of overlapping that happened to be about faces, and the stacking, the count and the ring were never about avatars.MPStackis what it was; see its page for the migration.Three things change on the way across. The count is now yours to draw —
overflowis a function of the number that did not fit, because that number is the whole of what the last item has to say and a generic stack cannot make an item that matches the others.ringis off by default, since a hairline in the page'ssurfaceis exactly wrong on a deck of cards that already has an edge. And the shared appearance is gone, which is the real cost:MPAvatarGroupsetsize,shape,variantandcoloronce for the whole run andMPStackdoes not know what its children are. Set what they share onMPConfigProvider, which coverssizeandcolor, and putshapeandvarianton the avatars.internal/avatar-group.tsgoes with it, andMPAvatarno longer reads a group context at all. Its own defaults are unchanged.One defect found on the way is worth recording, because
MPStackinherits the answer rather than the bug.MPAvatarGrouphad a heading on its page reading "The first avatar is on top", with the argument for it — a pile read from the start is read front to back — and nothing in the code said so. The overlap was a negative margin and nothing else, later siblings paint over earlier ones, and the run was therefore drawn last-on-top with every face's leading edge covered instead of its trailing one.MPStackwrites az-indexfor it, and for a second reason as well: an implicit order holds only until something in the pile acquires one of its own.
Changed
-
The window size classes are declared once, and are now a consumer's to move. They had been written out in five places: the numbers in
internal/window-class.ts, a set ofmatchMediastrings and two tables ofmin-[600px]:hiddenininternal/page-layout.ts, four@media (width >= 600px)blocks instyles.css, and theremvalues of the measure ladder, which are the same four widths at a 16px root. The module holding the numbers said in its own header that a third copy is how the grid and the hook end up disagreeing about wheremediumstarts.The stylesheet registers them as
@custom-variant mp-mediumand everything width-driven in it is written against those, so a project that runs its own Tailwind can redeclare a boundary after importing the library and move the grid, the measures, the visibility rules and its own utilities together. Both halves have to be redeclared —mp-mediumandmp-below-medium— because they are two registrations rather than one boundary a pair is derived from. A project on the compiledstyles.csscannot: that file is Tailwind's output and the queries in it are already numbers.They are not
--breakpoint-mp-*in@theme, and the difference is not cosmetic. Tailwind generates its.containerutility from every registered breakpoint, so four names in that namespace would quietly add fourmax-widthsteps to a class the consumer wrote and this library has nothing to do with.Two copies remain and cannot be removed — a media query resolves before any JavaScript runs, and JavaScript cannot see a registration the build consumed — so they are checked instead. A test reads both ladders and fails if they stop agreeing, and fails on a literal width written anywhere else in the stylesheet.
-
The measure ladder is read off the window size classes rather than written beside them.
maxWidth="md"was52.5rem, which is 840dp at a 16px root and only at a 16px root: a reader who had scaled their text up got a container that agreed with the media queries at no width at all. It is the boundary itself now, so a page that moved one moves its measures too. At the default root size nothing changes. -
MPTransferstacks its two lists on the named boundary. It always stacked at 600dp, MD3's own medium, spelledmin-[600px]:— which was a fourth copy of that number and one that would not have moved when the others did. -
internal/text.tsis now the one answer to where a character ends.MPAnimateTypinghad a grapheme splitter of its own, and the effects joining it in this release —MPAnimateSplitandMPAnimateScramble— would each have needed one. Three copies would be three opinions about what a character is, and they would only disagree on the text nobody tests with.It also answers where a word ends, which is the harder half: a word boundary is not a space anywhere east of Myanmar, so
split(' ')hands back a Japanese or Thai sentence as a single fragment and any effect built on it silently does nothing — in exactly the languages where nobody testing it would notice. -
A tab bar that scrolls now says so. It always scrolled —
overflow-x: auto, MD3's scrollable tabs — and there was no way to know. Measured at 320px a six-tab bar reports ascrollWidthof 526 against aclientWidthof 320, and macOS draws an overlay scrollbar that does not exist until it is used: a bar with four tabs off the edge and a bar with none were the same picture. Windows drew a real one, which is a signal and also fifteen pixels of furniture under the tabs, on one platform out of two.The scrollbar is hidden on both now — the pair
MPCarousel's strip already took — and the bar fades at the end that has more bar past it, at both when a reader is in the middle, and at neither when everything fits. Fading an end already reached would be the same lie as saying nothing.There is still no
overflow="wrap", for the reason there is noorientation: a tab bar on two lines has stopped being a bar, and the indicator has nowhere sensible to sit.The divider is left whole. It is the boundary between the bar and the panel rather than part of the run, so the fade is sized to everything above it and a second, opaque mask layer covers the hairline itself.
-
Every mark that comes and goes now arrives the same way. A checkbox's tick grew into its box over 200ms and a radio's dot grew out of the middle of its ring, and both had an exit to play on the way back out. The same mark drawn beside a chosen row in
MPSelect, inMPCombobox, and in anMPMenu's checkbox and radio rows was stamped on in one frame. They are the same event, and a tick that eases into a select while an identical tick is printed into the menu beside it reads as two libraries.internal/mark.tsholds the answer now: one transition and two from-states, because there are two kinds of mark. A tick is a stroke and starts at 0.6 — drawn from nothing it spends its first frames as a smudge too small to read as a tick, and the eye takes the arrival for a flicker. A dot is a disc, and a disc at any size is still a disc, so it grows out of nothing.MPCheckboxandMPRadioGrouphad those frames written out in full and now read them from the same place; neither changes on screen.The two select-shaped indicators are spelled differently, and the reason is Base UI rather than taste.
Select.ItemIndicatorandCombobox.ItemIndicatorreturnnullthe moment their row stops being the chosen one, sodata-ending-stylenever reaches the DOM and a tick that eased in would still vanish in a frame — in a list where the selection moves, that is the half of the event the eye is following. Both are kept mounted and drawn offdata-selectedinstead. The cost is one emptyaria-hiddenspan per row, in a column both lists already reserve the width of; the visible effect is that a row let go of fades out as the next one fades in, which withmultipleon anMPComboboxis the direction being watched. -
An avatar's picture arrives rather than lands.
MPAvatarwent from initials to photograph in a single frame: Base UI puts the<img>in the document the moment the file has decoded and takes the fallback down in the same commit, so what a reader saw was a substitution — and a row of eight avatars coming back from a slow network was eight separate jolts, each at whatever moment its own file happened to finish.MPImagehas faded its picture in since it was written, and the two draw the same thing at two sizes.It is
data-starting-styleand nothing else. The mount is the load, so there is no loading state to hold and no second render to reach it. What the picture fades over is the avatar's own container tone rather than the initials, since the fallback is gone by then — which is the right way round: a letter dissolving into a face is a different claim from a face being filled in. -
MPRating's fill travels to the score instead of jumping to it. Each star is a grey glyph with a coloured copy clipped over it, and the clip's width is the whole of what says what the score is. The colour under it has eased for as long as the component has existed; the width arrived in one frame. So the part carrying no information took 200ms and the part carrying all of it took none.short2rather than the library's usualshort4, for the reasonMPSlider's handle takes it: a rating follows a pointer sweeping across it, and at 200ms per star the fill would be a step behind the cursor for the whole of the sweep. At 100ms it reads as a wipe arriving under the pointer, which is what it is.motion-reduceturns it off — the width is real travel across the control, not a value settling in place. -
A dismissed
MPAlertleaves instead of disappearing, andonClosefires when it has. The press called back, the caller's answer to that is to stop rendering the alert, and so the element was gone in the same commit with nothing left to animate — and because an alert sits in the flow of the page rather than over it, everything under it jumped by the height of the message.Everything else in this library that leaves is portalled, and Base UI holds it open for the length of its own exit. An alert has nobody to do that for it, so it does it itself: the press starts the exit and
onCloseis the end of it, roughly 200ms later. That is a change to when the callback fires, and it is the moment the caller wanted — what an application does withonCloseis unmount the alert, which is exactly what should happen once the exit has finished. The press is handed over with it, held rather than re-created, since React has not pooled events since 17.The space goes with the message: the row travels between
1frand0fr, which is the one way to interpolate towards a height nobody knows. An alert that only faded would leave a hole where it was and move the page anyway, 200ms later and no longer attached to the press that caused it.Three details that are not obvious and were each found the hard way. The alert is drawn inside the collapsing row rather than being it, because a box with
box-sizing: border-boxcannot be shorter than its own padding and one asked to be the zero-height row bottomed out at 32px of empty container atmd.overflow: hiddenis on only while it is leaving, since an alert that went on clipping would cut the focus ring off its own dismiss button. And an alert with no transition to wait for — a reader who asked for reduced motion, or a page that took the transition off — reads the duration rather than waiting for atransitionendthat is never coming. -
A row that arrives in a list settles into it.
MPTransfermoves rows between its two panels andMPFilePickeradds them as files are chosen or dropped, and in both the row simply existed in the next frame. On a list of near-identical lines that is the change hardest to see: a reader who dropped four files onto a list that already held six had nothing telling them which four were new, and a transfer's whole gesture is "these ones went over there".A keyframe rather than a transition, for the reason the calendar's page turn is one — the row was not there, and a transition needs something that was. The part worth knowing is why the class is not simply on every row: a CSS animation runs when its element is inserted, so a blanket class looks like it would fire only for the rows the browser has just made, and it does not. Adding it to a row that is already drawn starts the animation there too, so the first move would replay every row in both panels. Both components keep the set of values that just landed and mark those.
It says nothing about which way a row came, which
MPTransfercould have.translateis physical, so the sign would have to be flipped by reading the computed direction under RTL, and it would be a slotMPFilePickerhas no answer for. A rise settles a row into a list in either writing direction. -
A field's supporting line opens rather than shoving the page down. The line under a control is drawn only when there is something to say, so a field that turned invalid grew by a line of text between one frame and the next. On a form validated on blur that is the whole page stepping as the reader tabs through it.
Two keyframes, because two different things happen. A field with nothing to say and one with something to say differ by a line of layout, and the row opens from
0fr; a field whose description has been replaced by an error differs by the words and the space is already there, so that one is a fade. Opening a row that is already open would collapse the line and re-open it, which is a flinch rather than a change of message.A page load is not a change, and the animation is switched on by an attribute the component sets only once it has watched the message change — a class that was simply always on would grow six description lines out of nothing the moment a server-rendered form hydrated. A message leaving is still instant: softening it would mean holding the last message the field had, and a field still showing an error it no longer has, to a screen reader as much as to the eye, is worse than a line that closes quickly.
Documentation
-
Breakpoints, one page for the axis the whole library changes along. The ladder was documented five times and completely nowhere: the grid's page had the table and the argument for it, the hooks guide had the trade between CSS and JavaScript, the sidebar's page had the first-paint problem, and a reader who wanted to know what changes at 600dp had to already know which component's page to open.
The five classes, why they are not Tailwind's, every prop that names one, the rule that an entry applies from its own class upward, when to reach for CSS and when for the hook, and how to move a boundary — both halves of it.
The grid's own section keeps the table and loses the
@themeblock it told readers to copy in order to line their utilities up with the grid, which has been wrong since the library started registering the variants itself. Lining up is now nothing to copy at all. -
The size table had drifted, and the row that said how many components there are was two releases out of date. It read "All hundred and eleven" against a library that exports a hundred and sixty-seven names, and the figures beside it were the 1.5.0 build's. The claim under the table — that the numbers are printed by the build rather than remembered — was the thing that had stopped being true.
The row is "Every export there is" now, which is the scenario
measure-bundle.mjsactually runs and a label that cannot go stale as components are added. The figures are this build's, and the stylesheet column is computed the way a bundler would compute it: the sheets concatenated and compressed together, rather than their individual gzip sizes added up, which over-counts the utilities they repeat. -
The class hooks, and why one of them silently does nothing in Tailwind.
mp-list-item__label,mp-accordion__titleand the sixty-five others are named in BEM, and Tailwind reads_inside square brackets as a space — so[&_.mp-accordion__title]:text-lgcompiles to.mp-accordion title, a<title>element inside an accordion, and matches nothing. No warning, no rule, no clue.The names are not changing: the
__convention runs through all sixty-seven hooks, and renaming the six that were noticed would leave a library with two conventions and the same trap in the other sixty-one.getting-starteddocuments both ways round it instead —\_\_in an arbitrary variant, or the plain name in a stylesheet, where a rule that holds for every accordion on a site arguably belonged. -
"Nothing chosen" is
null, onMPSelectandMPCombobox. Avaluewith no matching entry initemsis drawn as itself, so a form holding0for "not picked yet" sits there reading0in the field a reader is being asked to fill in. That was in the types and nowhere else, and MUI'sSelectdraws a blank in the same situation — which is exactly the sort of difference that survives a migration unnoticed.The behaviour is right and is unchanged: a field that stayed blank over a value it is holding, and will submit, is worse — and on
MPComboboxa value the list does not have is whatallowCustomis for. It is the sentinel that was undocumented.
Fixed
-
MPScrollZone's drag died on an unguardedsetPointerCapture. It throwsNotFoundErrorfor a pointer that is not active, and the throw abandoned everything after it: nodata-dragging, no scrolling, and the page's own text selection taken away with nothing left to give it back.MPPaneswas bitten by exactly this in 1.6.0 and carries the guard; this had the same line, and only Firefox showed it. -
MPImagesilently dropped a caller'sonLoadfor a picture the browser already had — precisely the case the component exists for. A cached image iscompletebefore React attaches anything, so theloadevent has been and gone, and Firefox does not fire it again where Chromium does.onStateChangeis reported from thecompletecheck as well as from the event, so it is now documented as the signal to use andonLoadas the passthrough it is. No event is invented to hand the caller one. -
MPBox,MPFieldsetandMPFormnow readMPConfigProvider'ssize. All three took'md'as a default parameter, which reads the prop and then stops — so an application that set the ladder once gotlgcontrols standing inmdroom, which is the one thing a size ladder exists to prevent.MPTooltipandMPTextLinkare deliberately not on that list: a tooltip drawn at a control's height is a slab, and a link inside a sentence is the size of the sentence. -
A picker asked for without its glyph could not be opened with a mouse.
startIcon={null}is the configurationfloatingLabelexists for — it is the only way the label ever rests on the trigger's own line — and it was the one configuration where a press did nothing at all.The label is what broke it. Pressing the trigger focuses it, the focus is what starts the label rising into the notch, and the label arrived under the pointer before the button came back up. The press therefore went down on the trigger and up on the label, and a press whose two ends disagree is delivered to their nearest common ancestor rather than to either — so the trigger never saw a click. It went unnoticed because all four pickers draw a calendar or a clock, and a glyph pins the label in the notch where it never moves.
The label is
pointer-events-nonein both states now. Resting, it always was, for a reason that reads across unchanged: it lies over the text the caret goes into, and a click on it has to reach the control so the caret lands where the pointer did. Shrunk, it is on the border line, so a press there now falls through to the control underneath rather than to a label that only ever forwarded focus. -
The
monocode theme drew no colours at all. It reached for--mp-sys-color-*, which are the roles a consumer sets and are undefined on a page that has not overridden them; the sheet's own derivations are--_mp-color-*. Found while writingMPTreeView's guide lines, which had the same bug and were the visible half of it. -
MPStepperandMPTimelineeased the bullet and snapped the line beside it. Advancing a step drew one event twice: the bullet filled with the accent over 200ms while the connector leaving it and the heading beside it arrived at their new colours in the frame the state changed. A sequence is read across, and the half that snapped was the half that carries the eye to the next step. Both components draw their picture out ofinternal/step.ts, so both inherited the same omission.The connector and the title take a shared
STEP_MOTIONnow. It listsborder-colorandcolorrather than a blankettransition-colors, which would put the bullet table'sbackground-colorandbox-shadowon every connector — a line has neither, and a transition naming a property nothing declares is a declaration the browser keeps. The bullet keeps its own wider list and gainsease-mp-standard: it had been on the browser's default curve, and two things travelling to the same state on different curves land at different times, which is the disagreement this was meant to remove rather than a second one to introduce. -
A time picker's column teleported under an arrow key.
MPTimePicker's columns are scrolled by hand, becausescrollIntoViewwalks every scrollable ancestor up to the document and the popup has not been positioned when the first reveal fires — which is the "opening a picker jumps the page" bug the function was written to avoid. What it did was assignscrollTop, and an assignment lands.So a reader holding ArrowDown through sixty minutes was reading a column that jumped every few rows, by exactly the amount needed to bring the next one into view and with nothing saying which way it had gone. On a list of numbers that all look alike, that is the difference between walking down a column and being handed a new one. The column carries
motion-safe:scroll-smoothnow and the scroll goes throughscrollTo, so the walk glides and a reader who asked for less motion keeps the jump.The reveal on open deliberately does not glide, and overrides the stylesheet to say so. Opening the picker scrolls three columns to the chosen time at once, and three lists easing into place inside a popup that is itself fading in is three things moving where one thing arrived.
-
A nested grid resolved the outer grid's column count and gutter. A responsive prop writes only the classes the caller named and the slots are inherited custom properties, so
columns={6}on an inner grid is one declaration — and above compact the inner grid was reading its parent's. Six columns on a phone and twelve on a laptop, from a grid that was told six at every width. The same for both gutters: an innerspacing={0}came out at the outer grid's from 600dp up..mp-gridalready reset fifteen slots at that boundary so an inner item could not resolve an outer one'sspan; its own three were never in the list. The failure is quieter than the item's — nothing is the wrong width on a phone, where a layout is usually checked, and the count is only wrong at the widths where the outer grid said something different. -
MPSpoilermoved the page twice on the way to being read, and could cut off the button that reads it. Two separate defects, and both are the same mistake in different clothes: something drawn in one state and not in the other, without the state that lacks it paying for the space.A
reversiblesheet mounted its way back out when it opened, so pressing Reveal grew the sheet by a button and a track of padding — 48px — under the reader's own pointer, and pressing Hide shrank it again. The row is drawn from the start now and merelyinvisibleandinertwhile the sheet is covered, which is the same pair the covered content itself gets. The cover is over it, so there is no hole to look at.And the cover was
position: absolute, which contributes nothing to a container's height. A sheet covering one short line with two lines of notice and a button was shorter than its own cover — 50px of box holding 74.5px of cover — and the sheet clips, so the button was cut off at the bottom edge. The cover is a grid item spanning every row now, and the sheet takes whichever of the two is taller.The rule, for anything else in the library with an overlay in it: if the overlay can outgrow what it overlays it is an item and not an
absolute, and a control that exists in only one state reserves its place in the other. -
The build never emptied
dist/, so a deleted module went on being published.tsconly writes. It has no notion of an output file that should no longer exist, andpackage.jsonshipsdist/whole — which means a component taken out ofsrc/keeps resolving throughmaterial-plus-ui/components/<name>for every release after the one that removed it, from a file no source has produced since.Nothing in the working tree says so, either. The removal is in the diff, the barrel no longer names it, and the only place the truth is written down is a directory that is in
.gitignore. Every measurement taken off the build inherits it: the@__PURE__count is a count of what is indist/, and the bundle figures walk a module graph rooted there.npm run buildnow empties the directory before anything writes into it. Its own step at the front rather than a flag ontsc, because half of what lands there is nottsc's — the two stylesheets, the ninety-five split sheets, the"use client"banners — and what has to be emptied is the directory rather than one compiler's share of it. -
Eleven modules shipped without
"use client", and one of them took a whole site down. A server component renderingMPContainerdied withAttempted to call useMPSize() from the server— and a page layout that usesMPContainermakes that every page of an App Router application.MPAspectRatioandMPAnimateLightingwere the same, andMPBox,MPTypographyand the sixMPAnimate*components were unmarked beside them, reachingReact.useRefthrough Base UI'suseRenderinstead.It arrived in 1.6.0 and the cause is worth writing down, because it is a judgement rather than a typo. The build marks a module by looking for markup, on the rule that a module that renders is a client module — and a component drawn with
useRenderwrites no element down at all, so the regular expression never saw one. Those eleven had nothing to fail on untiluseMPSize()anduseMPColor()gave them a context to read, and then all of them did at once.The judgement is made on the imports now: a module that imports a hook is a module that runs where hooks run, whatever it looks like from the outside. That also caught
useMPColorScheme,useMPReducedMotionanduseMPWindowClass, which compose client-only hooks out ofinternal/and were unmarked for a related reason. React's own hooks are still read from React's two builds rather than from the name, because that split is finer than a naming convention —useCallback,useMemoanduseIdare on the server build too, and a module using only those is not client for it.And because a judgement that missed once can miss again, the build now puts the question to
dist/as well: a shipped module that imports a hook and does not say"use client"stops the build, before it can stop somebody else's. A re-export is deliberately not an import —export … fromis exactly where a boundary belongs, and the barrels are built on it. -
A chosen
MPRadiosat half a pixel down and to the right atsmandlg. Reported twice by one application's readers, and reproducible on any 1× screen.The dot was exactly half the ring at every rung, which is MD3's own proportion. But the ring has a 2dp border and the dot is centred in what is left inside it, so the gap on one side is
(ring − 4 − ring / 2) / 2— a whole number of pixels only while the ring is a multiple of four.smandlgare 18 and 22, the two rungs this library interpolates between MD3's own, and they came to 2.5px and 3.5px. The geometric centre was exact; the browser then rounded it outwards.Their fills are 10dp and 12dp now — the next even number, 1dp over half, and on the grid.
xs,mdandxlare unchanged and still exactly half.MPCheckboxneeded nothing: its mark is drawn rather than boxed. -
MPGridItemquietly ate a caller's left margin..mp-grid-itemdeclaredmargin-inline-startfor every item in every grid, resolving to0pxfor the ones that had never asked for an offset — and a declaration of nought is still a declaration. These rules are unlayered and a Tailwind utility is inside@layer utilities, so unlayered wins whatever the source order is:<MPGridItem className="m-6">came out with three sides of its margin and not the fourth, which is about the hardest thing there is to look at and see. The application that reported it found out when a thumbnail sat against the wall of the panel it was padded away from.MPGridItemwrites adata-mp-offsetattribute when anoffsetwas passed, and the rule is.mp-grid-item[data-mp-offset]now. The arithmetic is untouched and so is every item that did ask. An explicitoffset={0}counts as asking — naming the property is asking for it, and nought is what the declaration resolved to anyway.
The measurements
npm run measure, against a build of this release and a build of 446426c for the baseline — the 1.6.0 source measured with this release's script, so the difference is the change and not the script. It reproduces 1.6.0's published figures, which is the check that says the two are comparable.
| Scenario | 1.6.0 | 1.7.0 |
|---|---|---|
MPBox |
0.4 kB | 1.7 kB |
MPButton |
3.0 kB | 3.0 kB |
MPTextField |
4.4 kB | 4.5 kB |
| Five components | 7.5 kB | 9.0 kB |
| Ten components | 11.6 kB | 13.3 kB |
| Everything | 81.5 kB | 126.8 kB |
Everything grows by 45.3 kB, and that is the honest headline: this release is 56% more library. Fifty additions, of which ten are charts and nineteen more are components — a chart frame, a calendar scale, an arc builder, five mark shapes and eight palette slots do not come free, and nothing here was made smaller to pay for them.
What matters more is the two rows above it, because almost nobody imports everything. Ten components move 1.7 kB and five move 1.5 kB, and neither scenario contains a chart: that growth is the shared vocabulary — density, elevation, transition and classNames — reaching components that already existed. MPButton does not move at all and MPTextField moves a tenth.
MPBox is the row that looks alarming and is the one worth reading carefully. It goes from 0.4 kB to 1.7 kB, and all of it is transition: the effect tables are read at runtime, so a component that accepts the prop carries all seven effects whether or not a caller passes one. It is paid once, not per component — the five- and ten-component bundles measure identically with the prop and without it, because the machinery is shared the moment a second display component is present. Only an application importing one of the seven entirely alone pays anything, and internal/transition.ts now carries the measurement so the question is not reopened.
The charts are 45 kB of the 45.3, and they are the reason to reach for the split stylesheet or a subpath import rather than the barrel. A dashboard drawing three chart types pays for three, not for ten.
everything also reports 49.7 kB deferred for the first time, which is not a regression but a disclosure: MPCodeBlock fetches its grammars at runtime rather than bundling them, and the figure is what would be in the bundle if it did not. The scenario is measured twice, once with highlight.js external and once inlined, and the difference is printed rather than hidden in the total.
The stylesheet grows from 114.9 kB to 144.8 kB, and 16.4 kB to 21.0 kB gzipped. The split sheets go from ninety-five to a hundred and twenty-nine, and the crossover — the point past which the whole sheet is the smaller download — moves from about thirty components to about forty, because the tokens file grew by the chart palette and the sequential ramp while the average component sheet did not.
The suite goes from 1924 tests to 2678. Four hundred and fifty of the new ones are the charts, and the ones worth naming are the ones that would have caught a defect rather than described a behaviour: a stacked band's lower edge measured against the same band drawn alone, a bar's geometry read off getBBox rather than off the path commands — a rounded bar's first h is the width less two radii, so parsing them compared a rounded bar with a square one — a sideways value label asserted to be inside the plot, and a span's label asserted to be dropped rather than clipped.
Every release of this package is in the changelog on the docs site and in CHANGELOG.md at this tag.