plass_ui 1.2.0 copy "plass_ui: ^1.2.0" to clipboard
plass_ui: ^1.2.0 copied to clipboard

A Flutter UI component library made of glass and gradients — tinted surfaces, coloured shadows, and light that follows the pointer. Accessible, themeable, dark mode built in.

Changelog #

1.2.0 - 2026-08-31 #

Added #

  • Five fields take a hotKeys map. PlTextField, PlNumberField, PlOtpField, PlCombobox and PlSelect now bind chords: hotKeys: {'Mod+Enter': save, 'Escape': cancel}. This is the half of the story the framework does not give away — there is no prop spreading here, so a key a widget did not declare a callback for was simply unreachable, and until now that was every key on a field but onSubmitted. The chord is spelled the way a key cap is spelled, in the vocabulary PlHotKeys draws: Mod resolves per platform, and Esc, Return, Cmd and Option fold onto the same keys their caps do. A shortcut a widget displays and a shortcut it binds are now one string rather than two that can drift — the argument PlCommandPalette made when it bound its own opener, with a private copy of the matcher to make it; that copy is now internal/keys.dart, and PlHotKeys reads its alias table and its platform from the same file. Three rules hold everywhere: the map is bound closer to the focused node than the widget's own key handling, so a caller can take a key from the control; a modifier is checked in both directions, so Enter does not fire on Shift+Enter; and a chord that matches is consumed, reaching neither the control's own keys nor the route above it. PlSelect is the one place nesting cannot settle — a FocusableActionDetector binds Enter closer than anything a field can wrap around it — so the trigger stands down from Enter whenever the map asks for it, rather than the caller losing a key they bound.

  • A PlDatePicker can stop at a month or a year. precision is PlDatePickerPrecision.day, .month or .year, and it is a floor: the calendar opens on the grid for that unit and pressing a cell in it answers, so a month picker's month grid is the last grid and there is no day grid under it at all. That last part is the point rather than a side effect — a card's expiry is a month, and a control that makes someone answer which day of December 2027 is a control that will be answered wrongly. The value stays a DateTime, normalised to the start of what was chosen: the 1st of the month, or the 1st of January, never whichever day or month the cursor happened to be resting on when the year was pressed. Two things follow it without being asked for — what the trigger writes when there is no formatValue (a new PlDateNames.monthYear, which swaps its two halves the way medium already does), and the footer's shortcut, which reads "This month" or "This year" rather than "Today" and brings two new PlPickerLabels entries with it. minDate and maxDate are then read at the same precision: a minDate of 15 July leaves July pickable on a month picker and hands back 1 July, because a bound on a control that returns a month is a bound on months. shouldDisableDate is day-granular and is not consulted at all.

  • PlCalendar. The grid a PlDatePicker opens, on the page. It is not a new calendar — it is the same internal/calendar.dart the four pickers already share, with the trigger and the popup taken away, which is why it arrives with the three views, the single tab stop and the arrow keys that step the month when they run off an edge already finished. The reason to have both is what each one is: a picker is a field that happens to open a calendar and belongs in a form beside other fields, and this is a calendar that is not standing in for a field — a booking page, an availability view, a date rail — where the grid is the interface rather than a way of filling one in. So it has no label, description or error. precision is the same floor the picker's is, and the value is normalised to the start of what was chosen. Two props are deliberately absent: there is no density, because padding on a grid of forty-two squares is what stops them being squares, and no readOnly, because a read-only field still shows a value a reader can select and copy while a calendar has nothing to copy — a grid whose every cell is inert is the disabled one. autofocus is off, the opposite of the picker's: a popup has just been opened by somebody who wants to be in it, and a calendar in a page has not. It differs from the React build in the two ways every date component here does — names and labels rather than a locale string, and no name, because a Dart form is not an HTML one.

  • PlStepper and PlStep. A process the reader is moving through, and where they are in it. It draws the same rail a PlTimeline does — and shares it: the bullet, the connector, the three states and the status arithmetic moved into a new internal/steps.dart that both widgets read, because a haloed bullet must not mean two things and two copies of a painter are two copies that drift. PlTimelineStatus and PlTimelineConnector are now typedefs onto the shared enums, so nothing a caller wrote has changed. What differs between the two widgets is what each is for: a timeline reports a sequence that already happened and nothing on it can be pressed; a stepper is the sequence, its steps are buttons and the current one owns a panel.

    The steps are a list rather than children, which is PlTimeline's arrangement and for its reason — the stepper has to reason about them, and neither "which one is complete" nor "which one can be reached" can be asked of an opaque Widget. That also settles the sharp edge the React build has to warn about, since there is no wrapper to hand it. linear is on by default while a step behind the reader is always reachable; horizontal puts the panel under the rail and vertical puts each panel inside its own step. optional takes a Widget rather than a bool, because there is no default string to fall back to: the package ships no translations. The current step is marked selected, the nearest thing the framework has to aria-current="step", and it is the only role claimed — a stepper is not a tab list.

  • PlTree. A hierarchy opened one branch at a time. It takes its nodes as data rather than as children — the opposite of most of this package, and the right way round here for one reason: a tree is recursive, and recursion written in widgets is a widget every caller has to write for themselves. PlTable takes its columns the same way and for the same reason.

    expanded and selected are Set<String>, both controlled like every other input here, and each callback hands back the whole set rather than the one id that changed. selection is single by default; none makes the tree a browser rather than a chooser — rows still expand and a press still reports through onItemPressed, but nothing stays lit. children: [] and children: null are different things, visibly: the first is a branch that opens and shows nothing, the second is a leaf with no twisty at all, which is what makes a lazily-loaded tree possible. The keyboard is the tree pattern — ↓/↑ walking the rows that are actually visible, → opening a branch and then stepping into it so a reader can open one without leaving the row that told them it was there, ← closing it or stepping out to the parent, Home and End to the ends — and one tab stop for the whole thing: every row's FocusNode but the current one carries skipTraversal, which takes it out of the Tab order while leaving it in the focus tree, so the arrows can still reach it.

  • PlImage. An Image is one widget and it works, so this says what it adds rather than assuming it. Three things: the space is reserved before the picture arrives, so what is under it does not move when it does — that is ratio, and it is the parameter worth reaching for every time; a failure is drawn rather than left as an empty box, and the default drawing is the semanticLabel, which is the one thing certainly available and certainly describing what is missing; and the two are one state machine, so a placeholder is never still behind a picture that has loaded, and a changed image starts again rather than inheriting the last one's success. image is an ImageProvider rather than a URL, because that is the shape every source has in common. preview opens the picture over the page in a PlOverlay and is off by default — a picture that grows when you press it is a promise that there is more of it to see. Without a ratio the widget is the picture's own intrinsic size, which is Image's behaviour and is deliberately not overridden.

  • PlEmpty. An empty list, a search that found nothing, a request that failed and a flow that has finished are the same arrangement — a mark, a line, a sentence, a way out — which is why they are one widget and not four, and why color is what tells them apart: secondary is "nothing here yet", danger is "something went wrong", success is "you are done". It draws no surface: an empty state is always inside a card, a table or a panel, and a sheet inside a sheet is two sheets — PlTable's empty takes a widget, and this is the widget it was waiting for. The part worth getting right is the way out, and it is the reason actions is a slot rather than an afterthought: a screen that says "No projects" and stops is a dead end. actions is a List<Widget> here rather than the React build's single slot, because a Dart caller has no fragment to put two buttons in.

  • PlStat. One figure, and what has happened to it. A row of these is the top of every dashboard, and the whole of what makes them worth a widget rather than three Texts is the change: a number on its own says what things are, and a number with a movement beside it says whether that is going anywhere. The colour of that movement is decided by improvesWhen and not by the sign, which is the one thing a naive version gets wrong — churn going up is not good news, and a green arrow on it is a dashboard lying to somebody. value takes a widget rather than a number, deliberately and twice over: how a figure is written is the screen's decision, and this package has no dependencies to write it with — package:intl's NumberFormat is the app's. The one number the widget does write is PlStat.formatChange, at most one decimal and a sign on a rise. The figure is Flexible and ellipsises rather than overflowing, because a figure long enough to fill its card should truncate rather than paint a yellow-and-black bar across the dashboard.

  • PlBackTop. The way back up, once there is a way back up to want. It is hidden until it is useful, and that is the whole design rather than a nicety: a button pinned to the corner of every screen from the first frame is one more thing covering the content, and on a screen short enough not to scroll it is a control that does nothing. While it is out of reach it is out of the semantics tree and out of the focus order rather than merely faded — a control a reader can reach and cannot see is worse than one that is not there. controller is a ScrollController, or the PrimaryScrollController when it is left out, which is what a ListView with no controller of its own attaches to and is therefore this framework's "the window". The scroll is animated and not when the platform has asked for less movement: a screen that flies past a reader who turned animations off is the exact case that setting exists for. There is no floating and no equivalent of the web's position: fixed — where the button goes is the caller's Stack.

  • PlConfirmProvider and PlConfirmProvider.of. await PlConfirmProvider.of(context).confirm(…) completes with the answer, so the branch after a question stays in the callback that asked it. The argument for a lookup rather than a widget in the tree is the one PlToastProvider made: what a caller has at the moment a question is warranted is a callback, not a place in the tree — and without this, adding a confirmation to one button means a piece of state, a PlModal kept in the tree beside it, and the work after the answer torn in half across a callback. alert is the one-button form.

    Three decisions worth stating, all shared with the React build. Cancel holds the focus by default: a confirm dialog exists to make somebody stop, and an Enter key that lands on the destructive action defeats the whole thing. Questions asked while one is open are queued, in order, with the sheet's content changing rather than the dialog closing and reopening; a provider that unmounts with questions outstanding completes them all false, because a future that is never completed is a callback that never runs its finally. And PlConfirmProvider.of asserts outside a provider rather than answering false, because a silent false is a delete button that quietly does nothing.

  • PlPopconfirm. A question asked where it was raised. The difference from PlConfirmProvider is not the words, it is how much it interrupts — a modal takes the screen away and is right for the question that deserves that, and this appears against the row's own delete button and leaves the rest of the list readable. The rule of thumb is what happens if they answer by accident: if they can undo it, this is the one. color defaults to danger here and to primary on a PlButton, and that is not an inconsistency — nobody reaches for a popconfirm to ask whether to save. onConfirm returns a FutureOr<void>, which is the Dart shape of "a promise is waited for": the button shows its loading state until it settles, the popup closes only if it completes, and a stray press is ignored while it runs. An error is caught and goes no further — keeping the question up is the whole of what the widget owes a failure. The focus lands on the confirming button, the other way round from the modal: a popconfirm is opened by the button it is asking about.

  • PlassTheme sets the defaults once. Seventy-seven widgets resolved size, color and density from a literal in their own constructor, which meant an application whose controls are sm said so at every call site — not a design decision, transcription. Those parameters are nullable now and resolve from the nearest theme instead, in four layers and in the order a reader would guess: the widget's own parameter, then whatever set it is in, then the theme, then the widget's own default. So a size: PlassSize.lg on one button still wins inside a PlButtonGroup inside a compact application. The date vocabulary comes with it — names, labels and weekStartsOn reach all five date widgets, which is what a locale is here, since the framework ships no Intl.

    Use PlassTheme.merge. The plain constructors replace the defaults in scope; merge keeps the ones above and the brightness with them, which is DefaultTextStyle's own bargain and is there for its reason — an InheritedWidget has no context to read an ancestor with, so merging happens where there is one.

    variant and elevation are deliberately not in PlassDefaults, and the absence is the load-bearing part. variant names what a surface is made of, and a PlButton is solid while a PlCard is glass because that is the arrangement rather than an unset default; elevation is per-widget semantics for the same reason, a control resting on the sheet at 1 and a field cut into it at 0. One value for either would not be a default, it would be a flattening.

    A new package test asserts the contract from both ends — that a theme produces an identical subtree to writing the parameter, on thirteen widgets, and that no constructor anywhere under lib/ still bakes a style axis in. Two bugs the refactor surfaced were fixed with it: PlAlert decided whether it was a live region by reading its unresolved colour, so an alert whose danger came from a theme announced itself as though it were a note; and PlSidebar touched a lazily-initialised width from dispose, which is an inherited-widget lookup on a deactivated element.

Fixed #

  • A PlPill could not stand anywhere it was not given a width, which included the arrangement its own documentation describes. Row, and a Positioned that named only its top and its start — how a lozenge that floats over a screen is placed — both hand a child an unbounded main axis, and the pill's outer column was CrossAxisAlignment.stretch. Stretching against an unbounded constraint asks for a tight infinite width, so instead of a wide pill you got BoxConstraints forces an infinite width and a cascade of RenderBox was not laid out behind it — an error naming a RenderConstrainedBox and nothing you wrote.

    The stretch was right and its reason has not changed: the row and the details panel under it are one width, so the panel spans the pill rather than sitting in the middle of it. What was missing is that there is not always a width to stretch to. The constraints are asked first now, and where there is nothing to fill the pill takes the width of its own widest part and centres the rest on it — so a pill with an open panel is still exactly as wide as the wider of the two, which is what stretch was doing all along.

    Nothing changes for a pill that already had a width: offered a bounded one it still spans it, and a loose constraint still counts, so a Wrap of pills is still a column of them. That last one is now written on the page, because it is the thing that sends somebody to a pill when they wanted a PlChip. This is a Flutter-only bug: the React pill is inline-flex and is as wide as its content wherever it is put, which is a difference the component page now states rather than leaving to be discovered.

  • Seven widgets laid themselves out on the wrong side of a right-to-left screen. The package is written in EdgeInsetsDirectional and PositionedDirectional almost everywhere, and "almost" is the failure: a physical left: is invisible in review, correct in the only direction anybody develops in, and wrong for a reader who would never file a bug in English. Found by writing the test first, and every one fixed in the widget rather than in the test.

    PlSlider was the worst of them, because it was wrong in three ways that have to agree: the run was painted from the physical left, a press was read as a distance from the left edge, and the left/right arrow keys counted up regardless — so an Arabic slider showed its minimum at the wrong end, jumped to the opposite value when pressed, and walked away from the arrow. PlChatBubble's tail stayed on the physical right while the bubble moved to the left, so the corner pointed away from its own sender. PlPill put the air meant for the leading glyph on the trailing side. PlAnimateMarquee ran the strip off the wrong edge, delivering the words backwards. PlScrollZone measured a horizontal strip's groups from the left edge and snapped to the wrong ones. PlNavigationMenu's vertical panel always flew out to the right, which on a right-to-left page is off the screen. And a date range's opening and closing corners — shared by PlDateRangePicker and PlCalendar — rounded the wrong ends of the run.

    A new package test now asserts the contract in two halves. The first drives a real Directionality(textDirection: TextDirection.rtl) tree for the behaviours that must turn over; the second reads every file under lib/ and fails on a named argument that names a side of the screen, against a short list of ten deliberate exceptions that each carries its reason — a measured offset, a corner that has to be handed to a painter already resolved, or a PlassSide, which names an edge of the screen on purpose. That second half is the one that catches the next widget. Nothing here changes the public surface, so the compiled size is unmoved.

    Four more places were rewritten as directional widgets without a visible change, because a rule with exceptions nobody can enumerate is not a rule: PlSidebar's inner rule, PlTable's pinned header, PlAnimateLighting's glow, and the horizontal padding in PlAccordion, PlCollapsible, PlSpoiler and PlCommandPalette.

  • A chord ending in Space or a numpad Enter now matches. The matcher folded the framework's key label by stripping its spaces, which turns the space bar's own label — a single space — into an empty string, so 'Space' could never match anything; and Numpad Enter folded to a name no cap is written with. A one-character label is now left alone and everything else goes through the alias table, so both reach the key printed on the cap. Reachable only through the new hotKeys maps, so nothing that already shipped behaved differently.

  • Switching a calendar's view no longer overflows the panel for a frame. The three grids' cells were matched by position across a view change, so each one's AnimatedContainer tweened from the grid it came from to the grid it was now in — and three month cells standing beside a fourth year cell is wider than the seven-cell panel holding them. Dropping from the month grid down to the days was the same thing four cells over. The grids are keyed now, so a view change builds fresh cells already at the right width.

1.1.0 - 2026-08-30 #

Added #

  • PlAvatarGroup. A stack of avatars, overlapping, with the ones that did not fit as a +n. size, shape, variant, color and elevation are set once on the group rather than on every avatar — a stack whose fourth face is a size out is not a stack — and an avatar's own value still wins, which is what marks one of them out from the rest. It reaches a face through an inherited widget, internal/avatar_group.dart following internal/button_group.dart one component over, which is also why PlAvatar's five axes are now nullable: null is "this avatar did not say", the stack answers next and the house default last. Two things are said differently. The faces are a List<PlAvatar> rather than widgets, because the stack has to know how wide a face is to lay the next one over it. And there is no negative marginEdgeInsets asserts it is non-negative — so the overlap is a Stack of faces each pushed one step further along; the stack takes the size of its widest child, so the row still measures exactly as wide as it draws, and the step is arithmetic off the group's own size rather than layout. The ring is the same idea as the stylesheet's and the opposite construction: a Flutter Border is drawn inside the box it is on, so the gap between two faces is a plate two pixels larger behind each one, filled with the page's own surface. It reads as the hole the near face is cut out of rather than as a line around anything, which is what keeps two circles of similar tone from smearing into one shape. The avatar page's hand-rolled Stack goes with it.
  • PlMenubar. The strip of words at the top of an application — File, Edit, View — each of which opens the same menu a PlMenu does, with a different trigger. What makes it a bar rather than a row of menus is that it is one SemanticsRole.menuBar whose words are menuItems, that the axes are set once on the strip and hold for every menu on it, and that the strip sits a rung below the control ladder at every step and takes the compact padding track even at the default density: a menu bar is a strip of words, usually inside something that already has a height, and sized as controls File Edit View would be three buttons in a row. Two things are said differently. The menus are data rather than composed children, which is the shape every list-shaped widget in this package takes. And crossing the strip does not walk through the menus: an open menu's dismiss layer sits between the pointer and the words, so they never hear it arrive — pressing elsewhere puts the open one away, and only ever one is open either way. That is also why the open one is marked in colour as well as in the semantics tree, since once a menu is open it is the accessibility tree.
  • PlColorPicker. A colour chosen by eye: a saturation square with a hue rail beside it, the arrangement every design tool has settled on because it puts every colour of a hue within a single movement of the pointer. The decision the whole widget turns on is that the panel's state is HSV and it never leaves that model — through RGB every shade of black is the same colour, so a picker that re-parsed its own output would snap the hue rail to red the moment the pointer reached the bottom of the square. An incoming value re-seeds the model only when it means something different, compared as a colour rather than as a string. The conversions are a new internal/color.dart, a hundred lines of arithmetic with no trigonometry in it, which is the entire reason a widget that computes colours brings nothing with it — in a package whose whole claim is that it has no dependencies. The tick on a chosen swatch is black or white by relative luminance, because a fixed white tick disappears on yellow, and the default swatches are a plain spectrum plus the greys rather than the package's six families: those are semantic roles, and a picker is asked for a colour rather than for a meaning.
  • PlCommandPalette. Everything an app can do, behind one field. It is not a PlMenu — a menu is a short list in one place and every row is visible before you look for it — and not a PlCombobox: what comes back is not a value, it is something happening. Filtering happens before the list so a group heading can be drawn from the same array the rows come out of, and keywords are matched and never drawn. Three things are said differently. open is required: there is no uncontrolled mode, because what opens a palette is a key bound on the whole app and an app that binds one already holds the state. The four list keys — the arrows, Enter and Escape — are read in the palette's own keyboard handler before the focus system, because the field has the focus and an EditableText consumes those keys itself; reading them first is the only way the field keeps every character while the list keeps its four. And the fold is case only, for the reason PlTransfer's is: Dart's core has no String.normalize. The opener is read with the same Mod-aware vocabulary PlHotKeys draws, so the cap on a row and the key that works cannot drift apart — and a row's own shortcut is displayed and never bound, because the app has already bound it.
  • PlFieldset. A group of controls that answer one question together, with a name on it, drawing no surface — a grouping is not a sheet, and the sheet already exists. What it owns is the legend, the gap the controls stand at, and disabled. That last one is the one thing said differently: on the web a <fieldset disabled> is an attribute the browser applies to every control inside, including one a widget three levels down built and never heard of, and there is no such cascade here. So it does the three things that cascade actually buys — the pointer taken away, the focus taken away, and the group drained the way every disabled surface in the package is. What it cannot do is make a field inside report itself as unavailable, which needs every widget to agree to look; a field that has to say so is given its own disabled.
  • PlForm. A form that knows which of its fields is wrong: the stack, a submit that collects every FormField's validity at once, and errors — an answer from outside the app's own validation, keyed by the name of the field it belongs to. It is not a form library, and it draws no surface. Two things are said differently from the React build and both are the same fact: there is no native form here. On the web a field's name puts it in the submission and its constraint validation is the browser's, so the form can collect values and route messages on its own; here a field is a widget holding a controller the caller already made. So onSubmit reports that the form is valid rather than a map of values, and errors is read explicitly through PlFormScope.errorFor(name) — the one piece of wiring this build asks for, and the reason that scope is exported rather than internal. submit() is reachable from a GlobalKey<PlFormState> or from anything inside through PlFormScope.maybeOf, because there is no native submit for a button to trigger.
  • PlNavigationMenu. A site's navigation: a row of destinations, some of which open a panel of more of them, claiming SemanticsRole.navigation and announcing a destination as a link and a panel opener as something that expands. The row carries no surface at rest — the words are the screen's own, and five bordered boxes across the top of an app is a toolbar rather than a navigation. Three things are said differently. There is no href anywhere, because there is no navigator in this package and no address to resolve: onPressed is where a destination is decided, exactly as on PlTextLink. The items are data rather than composed children, because the row has to know which item is which to keep one panel open at a time. And there is no controlled mode: String? cannot tell "the caller did not say" from "the caller says closed", so a controlled value would be one that could never be closed from outside — which panel is open is the pointer's state rather than the app's, and initialValue is all it needs. Each item anchors its own popup, so crossing the row swaps panels rather than growing one; the resize the React build does is Base UI measuring both panels, and it has no counterpart here.
  • PlTransfer. Two lists and the arrows between them, for a choice that is long — the columns in a report, the permissions on a role, the people on a channel. The model is one sentence, and it is the same on both sides: ticking is not choosing. The value is which side a row is on, the ticks are which rows the next press will move, and keeping them apart is what makes a press a deliberate act rather than a side effect of reading. Two things are said differently. A row's label is a String rather than a widget, because the filter reads it and a row the filter cannot read is a row that disappears from a search it could never satisfy. And the fold is case only: the React build strips combining marks as well, so cafe finds Café, and Dart's core has no String.normalize — pulling a package in so that a search box folds accents would put a dependency in every consumer's binary for the sake of one comparison, in a package whose whole claim is that it has none.
  • PlToggle and PlToggleGroup. A button that stays down, and a set of them that share one state. The rule the whole widget is built around is that off is neutral: a PlButton at rest is an action waiting to be taken, and a toggle at rest is a state that is currently false, so accent ink on an unpressed toggle would be saying it was on. On, the three materials give the same two answers a PlSegmentedButton's chosen segment gives, and the elevation does not move with the state — "on" is a fact about the thing beside the toggle rather than about how far the key is off the screen. The group squares off the corners facing a neighbour by publishing the same PlassButtonGroupScope a PlButtonGroup does, so a toggle picks either kind of run up by one route, and reports a list in both the single and the multiple case. One thing is missing on purpose: there is no roving focus, so each toggle in a group is its own focus stop. Base UI's roving tab index has no counterpart in widgets.dart, and a roving focus implemented badly is worse for a keyboard reader than the platform's own traversal — PlSegmentedButton carries the real thing where a value needs it.
  • PlSidebar, and the PlSidebarTrigger that brings it back. A column beside the screen's content, and a drawer once the screen is too narrow to hold one — two presentations of one panel, so a caller never swaps widgets at a breakpoint and the child is built once either way. It claims SemanticsRole.complementary and is named by default, because Flutter refuses a duplicated landmark with no label outright. Four things are said differently. The layout's collapse answer is measured against its own width rather than the window's, because a LayoutBuilder sees the constraints it was handed; giving the sidebar its own collapseBelow is what asks for the window instead. The trigger is not built at all while the sidebar is a column, rather than hidden by a media query — that class exists on the web to keep the button in the markup a server sends, and there is no first paint to hold together here. A collapsed panel with no title draws its semanticLabel as the heading, because a PlDrawer is named by what it draws. And the resize handle is a Semantics(slider: true) with the width as its value rather than a role="separator" with an aria-valuenow, since the semantics tree has neither: a handle is what it actually is to a screen reader, a control with a value that can be turned up and down. The dragged width lives in a ValueNotifier rather than in setState, which is the Dart spelling of the same decision the React build makes by writing it straight onto the element — nothing but one box depends on the number, and rebuilding on every pointer move would rebuild every row in the panel.
  • PlFooter. The sheet at the end of a screen, and the one component in the package that claims SemanticsRole.contentInfo — the landmark a screen reader offers as "the app's own information" rather than "more of the content". It claims it unconditionally, which is the one thing said differently: on the web the <footer> tag is only promoted to contentinfo at the top level of the document, and a widget has no top level of a document to be promoted at. It has no slots on purpose, which is the whole difference between it and PlHeader: a header's three regions are a fixed arrangement worth writing once, and a footer's content is four columns on one screen and one line on the next. What it decides is the sheet — the surface, the gutter, the measure, and the hairline along the top edge, which is the one that faces content.
  • PlHeader. The bar across the top of a screen: a brand at one end, the actions at the other, and whatever belongs in the middle. Its three slots are parameters rather than sub-widgets, for PlCard's reason — the arrangement is fixed and what a caller decides is what goes in each — and that the middle can be centred on the bar's own midline is only possible because the ends are the widget's to measure: both ends take an equal share by construction, so a logo one character longer does not move the navigation. size is a floor and not a height (md is 64, a 40 control with 12 either side), so a bar whose content wraps grows and keeps its padding. Three things are said differently from the React build. brand and actions are lists, because a slot is a row and the gap inside it is the bar's to decide — the same shape PlToolbar gives its two ends. There is no position: a fixed element has to span something, and a widget goes exactly where the screen puts it. And there is no banner landmark to claim, so a named bar is a SemanticsRole.region and an unnamed one claims nothing at all — Flutter refuses a region with no label, on the grounds that a landmark nobody can name is a landmark nobody can skip to.
  • PlPageLayout. The skeleton a screen is hung on: a header, a footer, one sidebar or two, and the content between them. It draws no surface, claims nothing but SemanticsRole.main around what it was given, and leaves the gutter and the measure to PlContainer — a layout that did both would be a second spelling of one idea. Two things are said differently from the React build, and both are the framework doing the work. There is nothing to measure: React has to write the header's height onto the root because a sticky bar sits across the top of the window without taking anything out of the flow, and a Column has already done that arithmetic — the band below the header is exactly what the header left. And collapseBelow is checked against this layout's own width rather than the window's, because a LayoutBuilder sees the constraints the layout was handed: an app shell inside a pane collapses when the pane is narrow, which a media query cannot say. null is the Dart spelling of "no floor was named". There is no scroll and no height — the layout fills the space it is given and what scrolls is whatever is put in child — and no skip link, because a link to a fragment needs fragments and a traversal order that is the document's.
  • PlTextLink takes a startIcon. Its one icon slot was the mark after the label, and it is not a general one: icon is about the link's destination and has an opinion, drawing the arrow for an external link unless showIcon says otherwise. There was nowhere to put the other kind — a favicon, a file type, a lock — so a caller built their own Row and paid for the gap by hand. startIcon is the plain widget the rest of the package spells the same way: nothing is drawn unless something is put there, and it rides at the link's own mark size a quarter-em from the label, the same as the one on the other side.
  • A PlScrollZone answers the wheel. A horizontal Scrollable reads the horizontal half of a scroll and a mouse wheel only ever produces the vertical one, so a shelf under the pointer sat still while whatever was behind it moved instead. wheel is on by default and takes only the vertical half of a gesture, and only while the strip has somewhere left to go: a trackpad's two fingers and a tilt wheel already scroll it sideways and are left to the framework, and the moment the strip reaches an end the wheel goes back to whatever is behind it — a shelf that swallowed it at both ends would be a hole a reader scrolls into. It goes through the PointerSignalResolver rather than acting where the event arrives, because a scroll view that has claimed the same event is deeper in the hit test and registers first, which is what keeps the two of them from both moving the strip.

Changed #

  • PlAvatar's shape, variant, size, color and elevation are nullable. Each of them used to carry its default on the parameter; now the default is applied when the value is resolved, and null means this avatar did not say — which is what lets a PlAvatarGroup answer for a face that stated nothing while a face that stated something keeps it. The arrangement PlButton already has inside a PlButtonGroup. Every call site that passed a value is unaffected; one that read the field back now reads a nullable.
  • A PlAccordionItem's title and subtitle wrap. They were each held to one line and ellipsed, which is the right rule for a table cell and the wrong one for a fold: an accordion is most often a list of questions, and a question is a sentence. A heading that ran past the header lost its end with nothing to press for the rest — a fold opens its body, not its heading — and the narrower the screen, the more of the question went. What wrapping costs is a header two lines tall, in the one component whose entire job is to change height. truncate puts both back on one line for the header the old behaviour was right for: a name out of a database sitting beside a control.
  • A PlScrollZone's buttons stand beside the strip rather than over it. buttonPlacement now defaults to PlScrollZoneButtonPlacement.inline. An item sliding under a button reads as something being hidden far more often than it reads as depth, and the two scroll buttons are the one thing on a shelf that must never be mistaken for part of what is on it — so the scroller stops where the button starts and an item is cut off at its edge instead. overlay is unchanged and is still what a shelf of pictures wants.

Documentation #

  • The README no longer calls the package a preview. It said a component's parameters and defaults were "settled enough to read" and what it would look like after real use was not — true of a package that had not reached 1.0.0, and no longer true once it did. In its place is the thing a reader of one half actually needs to know about the other: both packages hold the same components under the same parameter vocabulary and the same tokens, and they version independently, so this package's number and the npm one's will not always agree. Nothing under lib/ changed.
  • The README opens on the design rather than on the word "material". It led with "a component library with a material rather than a theme", meaning material as the stuff a surface is made of — which is not the reading a Flutter developer reaches for first, in a package that goes out of its way to import neither material.dart nor cupertino.dart. One paragraph describing the design itself takes its place, shared word for word with the React package and the repository's own README, and the points under it are written from the caller's side. Importing neither library is still said, in the bullet about bringing nothing in behind it, where it is a benefit rather than a headline.

1.0.0 - 2026-08-29 #

The rest of the library. PlButton shipped first and everything else follows it, ported from the React package the same way it was — value for value, with the places where "the same number" would have been wrong written down where the conversion happens.

Added #

  • PlAnimateTyping. Text appearing one character at a time, and it costs both kinds of reader nothing. The whole string is the widget's accessible label while the drawn copy sits behind ExcludeSemantics, so a screen reader is given the text once rather than being made to sit through the performance. And the box the whole string will need is held from the first frame by an invisible copy underneath, so nothing around it is laid out again as the characters arrive — Flutter lays a Text out from what it holds, so the space has to be reserved by something holding the whole string. The advance is by grapheme and not by code point: String.characters knows where the boundaries are, and a typewriter that advanced by code points would spend four frames assembling an emoji out of parts that mean nothing on their own. The text is one positional String rather than a widget or a list of children, because there is nothing to flatten: a typewriter's input is text.
  • PlAnimateHeadline. One line replacing the one above it, on a timer. Every line sits in the same cell — a Stack, which is as tall as its tallest child — so the box never resizes as the reel turns, and the lines that are not showing are drawn at zero opacity rather than taken out of the layout. It is deliberately not a ticker: interval is counted from the moment a line arrives rather than from the start of the cycle, so raising duration does not quietly eat the reading time. Passing index makes it controlled and takes the timer away outright — a controlled headline is somebody else's clock. rise is a double? where null is one line's own height, the same trade PlAnimateSlide's distance makes; there is no alternate, because a reel has no other direction to run in.
  • PlAnimateMarquee. Content scrolling steadily past, forever, with the copies laid end to end so the moment the first has left the second is standing precisely where it began. Three things are said differently. The strip is longer than its box by design, so it is laid out inside an UnconstrainedBox with Clip.hardEdge rather than a plain clip — a clip alone clips the paint and leaves the RenderFlex asserting that it overflowed. There is no percentage translate to resolve against the element's own box, so the strip is measured and moved by that many pixels, and the measurement decides the duration as well: speed is logical pixels per second, so a strip of four logos and a strip of forty move at a reader's pace instead of the long one becoming a blur. And where an entrance's reduced-motion answer is its finished frame, a marquee's is t held at zero — the content standing where it started. PlassAnimateRun learned to restart a simulation in flight when its duration changes, which is the case a marquee lives in: the strip is measured after the first frame, so the pass already running is the one whose duration has just become correct.
  • PlAnimateLighting. A light travelling around the outside of something, and the only way this package has of saying here without also saying and it moved. The light is a SweepGradient on a layer behind the child — first in a Stack with clipBehavior: Clip.none, inset by a negative spread — so it reaches past the content and still sits under it, and nothing inside is altered or overlaid. The arc turns between the two ends of the family as it travels, which is the rule every filled surface follows: a flat coloured arc would be the one place in the package where a coloured surface is paint. What moves is the gradient's own GradientRotation rather than the layer's, for the same reason the CSS animates a conic gradient's angle rather than rotating the pseudo-element: turning the layer swings its corners out past the content on every quarter turn. size has to agree with the radius of what is inside it.
  • PlAnimateAppear. A list of things settling into place one after another, and the effect belongs to the set rather than to any one item. Two things are said differently. The trigger is one gate above every child rather than one per child, so a visible list is a list that starts together and then staggers, not eleven widgets each deciding for itself when it is on screen; each child is then an ordinary run held back by its own delay. And it lays its children out, which the React build does not have to: there is no stylesheet here to put a display: flex on the container, so orientation and spacing are what a className would have done, and anything more elaborate belongs inside one child — which also makes that whole arrangement one step of the stagger. paused goes to the children rather than to the gate, so holding the set still holds it where it is instead of rewinding it.
  • PlAnimateBlink. Content pulsing between full opacity and a floor, on a symmetric cycle — full, faint, full — so however many times it runs it ends where it started. A blink that finished halfway would leave the widget permanently half drawn, which reads as a rendering fault rather than as an effect that ended. repeat is left at null here, the value that never stops, because a single blink is a flicker and nobody asks for a flicker. min is the floor, and it is worth raising for anything that has to stay readable while it pulses — and it is never the only thing carrying the message, because a platform with animations turned off shows none of it.
  • PlAnimateRotate. Content turning about a point, with two angles rather than onefrom alone is an arrival, and from: 0, to: 360 with a null repeat and Curves.linear is a spin that never lands. One thing is said differently and it is deliberate: both angles are degrees, not radians. The framework counts in radians and the design language counts in degrees — every gradient in the package is at 135° — so the conversion happens inside the widget, once, rather than at every call site. It is not for text: a rotated word is resampled along its whole length, which is exactly what the rule against transforming a control exists to prevent.
  • PlAnimateSlide. Content travelling in from one edge, and with no distance it travels the widget's own size — so it starts exactly out of frame and arrives without ever having been half drawn somewhere it does not belong. That is the one thing said differently here: distance is a double? in logical pixels and null means its own size, because a fraction of a widget's own size is what FractionalTranslation already means and there is no CSS length to spell. from is physical — PlassSide.top, .right, .bottom, .left — as it is everywhere in the package. Nothing around it is laid out again while it runs.
  • PlAnimateZoom. Content arriving from the middle of where it will end up — the same arithmetic as PlAnimateGrow at more than twice the distance, and always about the centre. That is the whole difference: a grow unfolds from somewhere, a zoom comes at you, which makes it the effect for the one thing on a screen meant to interrupt. There is deliberately no origin: a zoom anchored to a corner is a grow, and the package does not offer two spellings of one idea.
  • PlAnimateGrow. Content unfolding from a point. What separates it from a zoom is origin and how far it travels: a grow starts close to its final size and can be anchored to any edge, so it reads as something opening out of the thing beside it. origin is an Alignment rather than a CSS transform-origin string — the framework already has the type, and Alignment.topCenter reads better than 'top'. One other thing is said differently: with fade off there is no Opacity widget at all rather than one sitting at 1, so nothing in the tree claims to be doing something it is not and there is one fewer layer to composite.
  • PlAnimateFade, and the machinery the whole PlAnimate* set runs on. Content arriving or leaving on opacity alone — the plainest effect there is, and the one that suits the material: a Plass sheet is defined by what shows through it. Underneath it, internal/animate.dart is the Dart half of the React package's internal/animate.ts and keeps the same two ideas. Waiting is a held first frame, not a hidden widget: a visible fade sits at t = 0, faded out and taking its space, until it is scrolled into view — which is what fill-mode: both plus a paused play-state buys over there, and the reason an untriggered effect never flashes its finished state first. And a run that has finished stays where it ended, so an exit fade stays faded out. Four things had to be said differently. duration and delay are Durations rather than milliseconds, because the framework already has the type. easing is curve, a Curve. repeat is an int? where null never stops — there is no 'infinite' to write, and -1 would be a sentinel a caller has to look up; it is the same trade PlProgressLinear makes with a null value. And mode is PlassAnimateMode.enter / .exit, because in is a reserved word in Dart. trigger: visible watches the nearest Scrollable instead of an IntersectionObserver, and with no scrollable above it there is nothing to watch, so it runs — exactly as the React build does when the browser has none.
  • A test that keeps the package off Material. Nothing under lib/ may import package:flutter/material.dart, package:flutter/cupertino.dart or dart:mirrors, and test/package/framework_surface_test.dart fails if one ever does. Dart's tree shaker is whole-program and very good — the whole gallery, all 63 components, compiles to about 158 kB gzipped more than an empty Flutter app, and a light component costs single-digit kilobytes — but a MaterialApp reaches ThemeData, its typography and its ink machinery, and measures about 127 kB gzipped on its own. That is one import away from every consumer, it would pass every widget test, and no review would catch it. tool/size.dart is the on-demand measurement the numbers come from.
  • PlDateTimePicker. A day and a time in one popup — the calendar is PlDatePicker's and the columns are PlTimePicker's, both unchanged, and they sit at exactly the same height because both read the same cell ladder: seven rows each, so the popup is one rectangle rather than two of different sizes pushed together. The bounds do more work here than anywhere else and it is the one place this parts company with PlDatePicker: minDate is read at full precision, so a minimum of 09:30 on the 27th leaves the 27th selectable in the calendar and greys out the morning in the clock — what a "not before now" rule needs, and what a day-granular check cannot give. Picking a day leaves the clock alone and picking an hour leaves the day alone, because nobody reads a popup in the order it was written. The trigger wears the calendar glyph alone: a control cannot say two things at once.
  • PlTimePicker. A time of day, chosen from columns rather than a dial, with the bounds checked at the granularity of the column being drawn: with a minTime of 09:30 the hour 9 stays available — it contains allowed minutes — and the minute column is where 00 through 25 grey out. Two things are said differently. hour12 is a plain false rather than "whatever the locale does", because there is no Intl here to ask, and the words it uses when it is on are the PlDateNames.am / .pm that already come with the month names. And the chosen row is brought into view by computing an offset and jumping its own ScrollController, never ensureVisible, which walks every scrollable ancestor and would move the screen behind a popup that has only just been positioned. closeOnSelect is false — a time is two answers — so the footer carries a Done.
  • PlDateRangePicker. A span between two days: PlDatePicker with a second end, two months side by side as one calendar in two halves, and the band drawn between the anchor and the pointer before the second press lands — that preview is the whole affordance. Pressing backwards is the same range said in the other order. Two things are said differently here. value is a non-nullable PlDateRange with a PlDateRange.empty for nothing chosen, because a value that cannot be null is one fewer thing for a caller to guard. And a preset's range is always a callback rather than sometimes a value: a preset almost always depends on today, and "the last 7 days" computed once at startup is wrong for anyone who left the app open overnight. The popup needs an IntrinsicHeight around its row and an IntrinsicWidth around its presets — a portal hands its popup loose constraints, and a divider that stretches inside those is a divider asked to be infinitely tall.
  • PlDatePicker. One day, chosen from a calendar, on a PlTextField's shell wearing a calendar glyph. The header is what it is for: the month name and the year are each a button that opens a grid of its own, so any month of the year on screen is two presses and any year at all is three, and all three views share a width and a height so switching never resizes the popup. Six weeks always. This is also the one place the two packages genuinely part company: React gets Intl from the browser, so a BCP 47 locale string produces every month name and the order the header writes them in — the framework ships nothing of the kind, and a package that pulled package:intl in to fill the gap would be making a dependency decision on its consumer's behalf. So the words arrive as a PlDateNames: English by default, so a picker works with no setup at all, and three lines of DateFormat for an app that already has intl. format became formatValue, the same trade the indicators already make. The header's own controls are one step down the size ladder and both of its buttons truncate rather than overflow, because a month name is July in one language and септември in the next and the row has seven cells to fit inside.
  • PlCombobox. A field you can type into and also choose from, on the same anchored layer a tooltip and a select's list stand on. The shell is a PlTextField's to the pixel, exactly as PlSelect's trigger is. Two things had to be said differently. Holding a set is a second constructor, PlCombobox.multiple, rather than a multiple flag: one widget with a flag would have to hold both shapes of value and neither would be typed. And allowCustom became onCreate, a T Function(String) — React can default that permission to true because a value there is always a string or a number, and here it is a T that only the caller knows how to build, so the permission and the recipe are the same parameter. Nothing is committed when focus leaves: the query goes back to being the value, and a value the list does not have is only ever taken by taking its row.
  • PlProgressBox. A row of small glass plates that light up, and the shape that is about the material rather than the quantity. With a value the plates fill in order and the leading one partially, so four plates at 30% are one full plate and a fifth of the next rather than a quarter rounded off; each plate is a groove of its own for exactly that reason. Indeterminate they cycle, each held back by its own index, and what cycles is the fill's opacity — the gradient is there the whole time at full strength, because a gradient has nothing to interpolate toward when it is absent. The plates never move. One ticker per row rather than one per plate: the plates read the same controller at their own phase.
  • PlProgressCircular. A ring that fills, for where there is no room for a bar. It takes PlProgressLinear's parameters unchanged except for size, which is diameter here rather than thickness. The arc is the family's gradient rather than a flat colour, which is the one place in the package a Shader is built by hand: a stroke takes a shader rather than a decoration, so the 135° sweep every other surface gets from PlassCssGradient is asked for directly. Indeterminate, it draws a fixed quarter-arc and turns — the same exception a spinner already has — and slows rather than stopping under MediaQuery.disableAnimations. The value and the label sit beside it, because a number in the middle of a fourteen-pixel dial has nowhere to go.
  • PlProgressLinear. A bar that fills, and the one indicator that shows how much is left at a glance. The groove is the same neutral ink a PlSlider's rail and a PlSwitch's off track are cut in, and the run over it is the family's gradient — the material the button that submits the form is made of. Two things had to be said differently. format is a function rather than an options object: there is no Intl.NumberFormat in the framework to hand options to, and a package that pulled package:intl in to provide one would be making a dependency decision on its consumer's behalf. And the indeterminate segment travels on a directional Alignment rather than on inset-inline-start — neither is a transform, and both run the other way under RTL without being told. It is one merged semantics node carrying SemanticsRole.progressBar, or loadingSpinner and no value at all when it is indeterminate.
  • PlButtonGroup. A run of buttons that belong together. The corners that face a neighbour are squared off and the six axes are stated once for the set, exactly as in the React build — but two things had to be said differently. children is a List<Widget> rather than one child, because the group has to know which member is at each end to decide which corners to square. And the seam is made by not drawing the hairline that faces a neighbour, rather than by pulling the key back a pixel so two lines overlap: Flutter has no negative margin — EdgeInsets asserts it is non-negative — and the alternative is a Transform, which this library does not put on a control. Both arrive at one hairline per seam. The axes on PlButton and PlIconButton became nullable to make any of this possible: Dart cannot tell a default apart from a value that was passed, so this button did not say has to be a value the type can hold.
  • PlToolbar. A bar of controls: an application header, a screen's action row, the strip along the bottom of an editor. Three slots and a row — start and end pinned to their ends and child taking what is left, which is the arrangement every toolbar has ever had, so it is laid out here rather than left to a caller and a spacer they have to remember. It takes no height: a toolbar is as tall as the controls in it plus the size / density padding every other surface uses. There is no position — a fixed element has to span something and a Flutter widget goes exactly where the screen puts it — so all that is left of it is rounded, turned off for a bar held against an edge, because a rounded corner there is a gap with nothing behind it. It claims no toolbar semantics either, deliberately: that role is a promise about keyboard behaviour, and a bar that claims it without implementing it is worse for a keyboard reader than one that never claimed anything.
  • PlSpoiler. Content that is covered until somebody asks for it, and the cover is a blur rather than a hidden box: a reader can see that there is something there and roughly how much of it, and cannot read it by accident. Blur alone is not cover, so a wash of the screen's own surface goes over the top — which also gives the button something to stand on. It is the one widget in the package that is happy uncontrolled, because what is being remembered is a thing the reader did to this box rather than a value the screen owns. While it is covered the content is behind ExcludeSemantics, ExcludeFocus and an IgnorePointer — the three things inert says over there.
  • PlPopover. A sheet that opens beside the thing that opened it, on PlassAnchoredPortal — the same layer a tooltip and a select's list stand on, so all three stay stuck to their anchor through a scroll for the same reason. trigger is required here where React makes it optional: a browser can position a popup against the viewport with no anchor, and a LayerLink has nothing to follow without one. There is no modal and no alignOffset — there is no page scroll to lock, and the anchoring is a flip rather than a slide.
  • PlPill. A floating lozenge holding a small amount of live information, and the one stadium in the library — the exception the radius rule is drawn against, because this is not a sheet lying on the screen but an object hovering over one. The corner is half the row's minimum height rather than half of whatever the pill has grown to, so a two-line pill keeps the corner it always had. There is no position: a fixed element has to span something to be centred in it, and a Flutter widget goes exactly where the screen puts it.
  • PlDrawer. A panel attached to one edge of the screen, and two things in one widget because they are the same panel: overlay opens over the screen on PlassPortal — the same layer a modal and an overlay stand on, so one opened over the other shows no seam — and inline is simply part of the layout. There is no variant and no elevation, because a panel that has taken an edge of the screen has already answered what those ask. There is no trigger either: the app opens it by setting open, and the button that does it is the app's own.
  • PlCollapsible. One section that folds, standing on its own — the same fold a PlAccordion is a set of, with nothing to coordinate with. The panel is a SizeTransition clipping a body that never changes size, so nothing is transformed and no text is resampled. trigger becomes triggerBuilder, which is forced: a React element can be cloned with new props and a Dart widget cannot be handed a tap handler after it was made. keepMounted has a sharper reason here than there — a Flutter State goes with its widget when it leaves the tree, so without it a folded-away field forgets what was typed into it.
  • PlCarousel. A strip of slides, one of which is in view, on a PageView — Flutter's own scrolling with snap points, so a swipe is the platform's and not a gesture handler imitating it. It gains aspectRatio, which the React build has no need of: a browser's strip is as tall as what is in it and a PageView lays every page out at the viewport's size. autoPlay is off by default, pauses under the pointer, does not start at all under reduced motion, and does nothing while the carousel is frozen.
  • PlBox. The plainest surface in the library: a sheet of glass with content on it and nothing else claimed. size is the size of the sheet — its radius and its padding — rather than a height or a type scale, which is the one place in either package where the word means something else. It gains one parameter the React build has no need of: clipped, because overflow: hidden is a class over there and a widget here, and a clip cuts anything a child draws outside itself.
  • PlScrollZone. A strip of anything laid out in one direction and scrolled in it, in as many lines as you ask for. The mechanism is an ordinary SingleChildScrollView and everything on top is a way of driving one; a group is measured off what was actually laid out rather than assumed, so a press moves one whole column and not a fraction of one. spacing is a length here rather than a step on the React build's spacing scale, controller is offered because Flutter drives a scroll view with one, and drag puts PointerDeviceKind.mouse back into dragDevices — which Flutter leaves out for the same reason a browser does, and which a shelf is exactly the place to ask for.
  • PlAspectRatio. A box that keeps a proportion and draws nothing at all. fit is nullable here and null by default, where React defaults it to cover: in a browser object-fit only ever reaches a replaced element, and a FittedBox reaches whatever it is handed — so a fit applied by default would scale a column of prose.
  • PlPanes. The one place Flutter makes the same idea easier rather than harder: the React build needs a ResizeObserver because CSS measures itself, and a LayoutBuilder is handed the extent on every layout pass. PlPaneSize is two constructors where React has a number | string union, a handle is slider semantics because Flutter's tree has no separator role and no valuenow, and none of the three drag hazards the other build has to work around exists here at all.
  • PlMenu. The one component whose shape differs between the two packages, and it is forced: React composes its rows because Base UI reads the DOM they are written into, and there is no tree to walk here — so the rows are a sealed PlMenuEntry hierarchy the menu is handed. Focus stays on the trigger while the popup is up, which is what PlSelect does; the arrows, Home, End, Esc and typeahead are bound there, and the pointer moves the same highlight the keys do. Submenus open on hover and on the arrow key that points at them, and the arrow follows the writing direction. There is no PlContextMenu: no right-click gesture means the same thing on every platform this package runs on.
  • PlFloatingBottomNavigation. The other half of the bar above, and a separate widget for the same reason it is in the React package: one is attached to the edge of the screen and the other is not part of it at all. There is no full-width strip to build here — a fixed element has to span something, and a Flutter widget goes exactly where it is put — so the bar is only as wide as its capsule. The key marking the current destination is one widget measured off whichever disc it belongs to and animated between them, the way PlSegmentedButton's tile is: a surface fading up on one disc while it faded out of another was two objects changing colour, where there is only ever one key and where it went is the whole of what the bar has to say.
  • PlBottomNavigation. Destinations as descriptions rather than widgets, and an item's label is a String rather than a widget: it is the name that is drawn and the name that is announced, and only a string can be both. There is no position — a Flutter screen has no page scroll to opt out of — and no href, so onChanged is where a router is called. The current destination is marked selected, which is the nearest thing to aria-current that does not claim the item is a toggle.
  • PlOtpField. One editor behind the whole row, drawn as slots — Flutter's text input is a single connection to the platform, and the React build's one-<input>-per-slot would be six keyboards fighting over one code. It is laid out over the row at zero opacity rather than taken off screen, because an input has to be measured to hold that connection. Rejected characters go through a formatter of the component's own rather than FilteringTextInputFormatter, so a refusal has somewhere to go instead of vanishing.
  • PlRating. The fraction is the filled star laid over the empty one and clipped by a CustomClipper, from the leading edge — so nothing is transformed, nothing is scaled, and it fills from the right under RTL without being told to. There is no radio group to inherit a keyboard from, so the arrows are bound on the row and every choosable fraction is a semantics node in a mutually exclusive set. readOnly drops the gestures entirely for one image node.
  • PlIconButton. A disc with a glyph in it, and almost none of it new code — everything is PlButton's. label is required for the reason it is in the React build: an icon button with no accessible name is the commonest accessibility defect a component library ships. PlButton gains one escape hatch, borderRadius, which is the door the React build already has through an inline style.
  • PlGrid and PlGridItem. A cell is a description rather than a widget, for the reason an accordion's folds are: the grid packs its members into rows by the columns they take, and a Widget is opaque. PlassResponsive<T> arrives with them, with the base value positional — Dart has no union type to carry "a number or a map", and the breakpoint is resolved against the window, which is what a CSS media query measures. wrap: false scrolls sideways rather than overflowing: a Flutter screen has no page-level overflow to leave the scrolling to.
  • PlContainer. The gutter and optionally a measure, drawing nothing. maxWidth is a nullable PlassSize where React has a 'none' in the union — Dart already has a word for "not set". The measure is the same ladder unit for unit, because a rem against a 16px root and a logical pixel are the same length.
  • PlTypography. The type scale on its own, so a page can use it without wrapping its prose in a card. level sets the size and whether the line is announced as a heading; there is no size beside it, because a size would let a caller ask for an h1 at xs. PlTypography.rich is the span form.
  • PlAvatar. A picture, or the initials, or a silhouette — never an empty box. image is an ImageProvider rather than a URL, which is the shape every image in Flutter has, and the fallback is shown until the first frame arrives and for good if none ever does.
  • PlBadge. content and count are two parameters rather than one, because max and showZero only mean anything for a number: the type is the question the React build has to ask at runtime.
  • PlChip. A pressable chip and its delete affordance are two separate focus stops, neither inside the other's gesture recogniser — the same shape the React build reaches for, there because a <button> inside a <button> is invalid and here because a nested recogniser would take one tap twice.
  • PlCard. The sheet, with the title, subtitle, header action, body and footer laid out on it. onPressed is what render={<a href>} was mostly reached for: it makes the card a real focus stop, announced and keyboard-reachable, and it lifts.
  • PlAlert. The three shapes, the six severities and their marks. Flutter has one live region rather than two politeness levels, so role="alert" against role="status" becomes whether the alert is a live region at all.
  • PlSlider. One thumb per value, so a range is the same widget with a longer list. There is no native range input to inherit a keyboard from, so the keys are bound here: arrows step, Page takes a tenth of the range, Home and End jump to the ends. The thumbs cannot cross.
  • PlSegmentedButton. The tile is measured off the chosen segment and animated as a rectangle — nothing is transformed, which is what lets the house no-transform rule survive a component whose whole point is that something moves. One focus stop for the set, arrow keys within it.
  • PlPagination. The same window arithmetic as the React build, down to the gap of exactly one page being filled with that page rather than an ellipsis, and every button in the row is a real PlButton. There is no getPageHref: nothing crawls a Flutter app, so the row is buttons and onPageChanged is where a router is called.
  • PlTextField. Single- or multi-line, with the label, the description and the error as part of the component. Built on EditableText rather than on a TextField: the latter is Material, and what Material adds on top — the decoration, the counter, the ripple — is what this component is instead of. The cost is named on the page: a selection has no drag handles, because those belong to Material and Cupertino.
  • PlCheckbox, PlRadioGroup and PlSwitch. Every one of them is controlled — handed a value, reporting what the value should become — which is how Flutter's own controls work and why there is no defaultChecked anywhere in the package. A radio group is generic in its option's type, so value and onChanged are checked rather than dynamic, and it owns the roving tab index: exactly one option is in the tab order, the rest are wrapped in an ExcludeFocus, and the arrow keys move the choice with wrapping.
  • PlTextLink. A line under a word, and no href: Flutter has no navigation of its own, so where a link goes is the app's and onPressed is where it is decided. Announced as a link rather than as a button, which is what puts it in a screen reader's list of links.
  • PlHotKeys and PlKbd. Mod resolves per platform from defaultTargetPlatform, and every key drawn as a glyph announces its real name — read out is "place of interest sign", which is not a key anybody has.
  • PlList and PlListItem. The list is the sheet and the rows are what is on it, so size, density, color and dividers reach a row through an InheritedWidget. A row's action is a separate focus stop from the row itself.
  • PlBreadcrumb and PlTimeline. Both take their members as descriptions rather than widgets — Flutter's own idiom, the one DataColumn uses. The reason is that both have to reason about their members: which step is the current page, how many there are, which the fold takes out, which connector is the last. A Widget is opaque and none of that can be asked of one.
  • PlDivider. A rule between two things, horizontal or vertical, with an optional label set into it. length wins over a tight parent — a divider very often sits in a Column with crossAxisAlignment: stretch, where a bare SizedBox would be handed a tight width and lose.
  • PlSkeleton. The shape of something that has not loaded yet, in the three shapes a layout is made of. The travelling highlight is one gradient slid across the box by a GradientTransform rather than a second widget laid out per placeholder, and it becomes a colour pulse where the platform has asked for less movement — kept running either way, because a skeleton that holds still is indistinguishable from an empty box that finished loading with nothing in it.
  • PlBlockquote. The rule, the quotation mark and the attribution. The mark is drawn rather than typed, unit for unit out of the same 16-unit box the React package's SVG uses.
  • PlHighlight. The search as well as the styling. It takes a String rather than a widget tree, which is the one real difference from the React build: a Widget is opaque, and there is no reaching the text inside one you were handed.
  • PlIcon. A glyph at a known size in a known colour, for whichever icon set the app chose. The glyph is told how big it is three ways at once — through IconTheme, through DefaultTextStyle, and by the box it is laid into — so an Icon, a CustomPaint that reads the ambient theme and a bare character all come out the same size. color is nullable and defaults to null, which is how "inherit" is spelled in a language with no such keyword.
  • PlAccordion and PlTabs. Both take their members as descriptions rather than widgets, and both fold the panel into the member that opens it — a PlAccordionItem carries its body, a PlTab carries its panel, so there is no second list to keep in step. The accordion's value is a Set<T> even with multiple off, because closed is a set too. The tab bar owns one focus stop, moves the choice with the arrow keys on whichever axis it runs, and builds only the panel that is open: a tab that is not open costs nothing, and the price is that its state has to be held above the bar.
  • PlTable. Laid out by Flutter's own Table, which is where the table, row, cell and column-header semantics come from — a screen reader announces the column with every number under it, for free. A column says how to get a cell out of a row and that is all it says: cell is required here, because Dart has no row[key] on an arbitrary type. Width comes in two forms rather than one — a length in pixels, or a flex share of what is left, which is what the React build's width: '30%' already meant. maxHeight caps the grid and the rows scroll inside the sheet past it, with the caption left above what scrolls. stickyHeader pins the column names over that scroll, and the thing that used to make it impossible is the thing it avoids: a pinned header is not a second grid here. There is still one Table with its header row in it, and the band laid over the top is a copy of that row with each cell put in a box of the width the grid actually laid the real one out at — so one grid still decides every column, and the band only repeats what it decided.
  • PlNumberField. The shell is a PlTextField's to the pixel, and what sits on it is a real numeric control: the arrow keys and the steppers move by step with Shift and Alt changing how far, a held stepper repeats after a pause, and the value clamps when it settles rather than while it is being typed. format is two functions rather than an Intl.NumberFormatOptions — one writes and one reads — because there is no Intl.NumberFormat in the Dart SDK and this package has no dependencies. The arrow keys are bound inside the field, closer to the editor than an app's own text-editing shortcuts, which is what keeps the up arrow moving the number rather than the caret.
  • PlOverlay and PlModal, and the layer under both. A sheet that takes the page away lifts itself into the nearest Overlay — where it is written does not matter, and it takes up no room there — and what it brings with it is the backdrop, the fade, the pointer held outside, focus held inside and put back on the way out, and Escape. Both are controlled: the × and a press outside report rather than act, which is why there is no trigger and no PlModalClose here. A modal's body is the only section that scrolls and the only one that gives way when the sheet runs out of screen — a header that scrolled away would take the modal's name with it.
  • PlTooltip, with the anchored layer under it. A popup that hangs off something rather than covering everything: a LayerLink keeps it stuck to its trigger while the page scrolls, and the collision handling is a flip and not a slide — sliding would need the position recomputed against the viewport every frame, which is what the link exists to avoid, and a wedge that has crept off its trigger points at nothing. open is a bool?, and null is the tooltip driving itself from the pointer, a long press and focus: the one place in the package where a component owns its own state, because nobody has an opinion about whether a pointer is resting on a button. PlTooltipProvider shares one delay across a toolbar.
  • PlSelect. The trigger is a PlTextField's shell wearing a chevron and the list is a PlTooltip's plate, both on purpose: a form where the select is a different height from the fields around it looks assembled rather than designed, and a screen with two kinds of floating sheet on it has one too many. It is generic in its value's type, which is the one place it parts company with the React build's advice — nothing is submitted here, so the value can be the thing itself. The keys stay on the trigger: arrows move the highlight, Enter takes it, Escape closes, and focus never has to go anywhere and come back. The trigger holds its width at the longest label it could ever say, laid out and not painted, so choosing a shorter option does not shrink the field out from under the pointer that chose it.
  • PlToast. A PlToastProvider wrapped once around the app, and PlToastProvider.of(context).show(...) from anywhere under it — a controller rather than a widget, because what a caller has at the moment a toast is warranted is a callback, not a place in the tree. The stack needs no Overlay: the provider is already above everything it has to cover, and nothing in the strip is told to ignore the pointer because an Align hit-tests its child and not the room around it. Showing an id already on screen changes that toast in place, showFuture makes one message follow a future from "working…" to its answer, and the clock stops while the pointer rests on a toast — which is a reader reading it.
  • PlChatBubble. The corner nearest the speaker cut short — the library's one piece of chat vocabulary, doing the job a drawn tail does elsewhere without hanging a triangle off a sheet of glass that was cut with a straight edge. The row runs the other way for the reader's own message by reversing its direction rather than its list, so a thread in Arabic is mirrored without being told twice. The typing dots light in colour only and never move; the delivery mark carries its own name for the readers a double tick says nothing to; and the link card's surface is mixed out of the bubble's own ink, because it is the one part that has to work on both a filled surface and a bare one.
  • PlFilePicker, which does not pick. This package has no dependencies and reaching the file system is a plugin's job in every Flutter app that does it, so onBrowse is where the app's own picker runs and everything after that is the component's: the rules, the list, the removal and the box. accept is applied to whatever comes back whether or not the plugin was told the same thing — a rule the component states and does not enforce is not a rule — and maxFiles counts against what is already held rather than against one batch, which is the difference between "you may add five files" and "you may end up with five". There is no OS-level drag either, so dragging is a flag an app that has one sets: the look of the state is the component's, the detection is the app's.

0.0.2 #

A packaging release. No component changed, and no code in lib/ did either — everything here is about what pub.dev was shown, and what it was shown was wrong in three ways.

Fixed #

  • The homepage pointed at a host that does not exist. The documentation is at plass.cdget.com; pubspec.yaml claimed plass-ui.cdget.com, which resolves to nothing. pub.dev checks the URL, so this cost the package its pubspec points outright.
  • The description was 195 characters, and pub.dev's ceiling is 180. Shortened to 174 without dropping anything it was actually saying.
  • The gallery was not in the published archive. .pubignore excluded example/, so the directory 0.0.1's notes describe as shipping was in the repository and nowhere else — and pub.dev reported the package as having no example. It ships now, along with an example/README.md that is a whole running app in forty lines, since the gallery's own main.dart opens on the machinery that lets the documentation site embed it rather than on anything a reader wants first.

0.0.1 #

The first release of the Flutter package, and a preview rather than a product. One component ships; what is actually being released is the shape everything after it will be poured into — the tokens, the scales, the theme and the test setup.

The design language itself is not new: it is the one the React package already ships, ported value for value. The documentation is one site for both, with a framework switch in the sidebar.

Added #

  • PlButton. variant (solid · glass · ghost), size, color, density and elevation off the shared vocabulary, plus startIcon, endIcon, loading, readOnly, disabled, fullWidth, semanticLabel and the usual focusNode / autofocus / onLongPress. onPressed: null disables the button, as it does everywhere else in Flutter.
  • PlassTheme, and the fact that you do not need it. A component resolves its tokens from the nearest one and falls back to MediaQuery.platformBrightness when there is none, so a button dropped into any app is already in the right theme and follows the system switch. The theme is an override, not a prerequisite.
  • PlassTokens — the whole token sheet as Dart: six colour families, three glass strengths, the elevation ladder, the tinted lift, the radii, the durations and the two curves. Four values are hand-picked per family and everything else is derived, so adding a family is one entry in PlassColor plus its colours.
  • The shared vocabularyPlassSize, PlassColor, PlassVariant, PlassDensity, PlassElevation, PlassOrientation, PlassSide, PlassAlign, PlassCorner. An md is 40px on everything, and it is the same 40px the React package means.
  • Nothing from material.dart or cupertino.dart. The package is built on package:flutter/widgets.dart alone, which is what lets it sit inside a Material app, a Cupertino app or a bare WidgetsApp without dragging a second design system in behind it — and what makes it indifferent to those two libraries moving out of the framework.
  • No dependencies at all, and no assets or platform channels.
  • A gallery, under example/. It runs as an app on any device, and its web build is what the documentation site embeds behind every Flutter preview — so the previews on the site are the real package, not a screenshot.

Matched to the React build on purpose #

Three things do not port across as the same number, and each is converted rather than copied:

  • Shadow blur. CSS defines a shadow's blur radius as twice the Gaussian standard deviation; Flutter's BoxShadow.blurRadius converts with σ = r × 0.57735 + 0.5. Handing Flutter the CSS number makes every shadow about a fifth softer than the one it is copying, so the ladder is converted on the way in.
  • The solid gradient. LinearGradient(topLeft → bottomRight) runs along the box's diagonal, which is only 135° on a square; on a wide button the sweep visibly flattens. The endpoints are computed per paint the way linear-gradient(135deg, …) specifies instead.
  • The glass hairline. BoxShadow only casts outward, and the light along a sheet's top edge is an inset shadow. It is reproduced exactly — the shape minus the same shape moved down a pixel, clipped back to the shape.

And two visible differences that are not bugs: the font is whatever the host uses (neither package sets one), and PlassDensity.standard is spelled 'default' in React, because default is a reserved word in Dart.

1
likes
160
points
108
downloads

Documentation

API reference

Publisher

verified publishercdget.com

Weekly Downloads

A Flutter UI component library made of glass and gradients — tinted surfaces, coloured shadows, and light that follows the pointer. Accessible, themeable, dark mode built in.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#ui #widget #design-system #glassmorphism #theming

License

MIT (license)

Dependencies

flutter

More

Packages that depend on plass_ui