Skip to content
Lenso UI

v3.2.0

Calendar week/day views and year picker on React Aria 1.18, plus breaking Radio, Checkbox, and Switch composition changes.

June 15, 2026

Calendar gains week and day views, a reworked year picker, and range demos built on React Aria 1.18. Autocomplete adds a Virtualizer example for large option lists. Tooltip adds theme variables for global show and hide delays. The toggles (Radio, Checkbox, Switch) move to React Aria's *Field + *Button composition. This release also rolls in patch fixes for virtualized lists, grouped-field autofill, toast and fieldset behavior, and scroll and RTL styles.

⚠️ Breaking changes: Radio, Checkbox, and Switch move to an explicit *.Content composition — *.Control nests inside *.Content, the label is plain text inside *.Content (no nested <Label>), and Description/FieldError become siblings of *.Content. See Breaking Changes.

Installation

Update to the latest version:

npm i @lenso/tokens@latest @lenso/ui@latest

Using AI assistants? Prompt "Hey Cursor, update HeroUI to the latest version" and your AI assistant will compare versions and apply the necessary changes. Learn more about the HeroUI MCP Server.

What's New

Calendar

Calendar and RangeCalendar gain week and day views, plus new React Aria 1.18 calendar props.

  • Week / day views: visibleDuration renders multi-week or single-day layouts
  • Multiple selection: select several dates in a single Calendar
  • React Aria 1.18 props: weeksInMonth and isDateUnavailable(date, anchorDate) for ranges
  • Internals: month labels use React Aria's CalendarHeading, and the year picker is rebuilt on React Aria calendar hooks

Week view:

"use client";/** HeroUI v3.2.6 derived work. Copyright NextUI Inc. Apache-2.0. Modified for StyleX and native Base UI Select. */import { Calendar } from "@lenso/ui";import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { CalendarHeader, CalendarGrid, DurationSelect, layout } from "./demo-parts";export function WeekView() {  const [weeks, setWeeks] = useState(1);  return (    <div {...stylex.props(layout.controlsStack)}>      <DurationSelect unit="weeks" value={weeks} onChange={setWeeks} />      <Calendar aria-label="Week view" visibleDuration={{ weeks }}>        <CalendarHeader />        <CalendarGrid />      </Calendar>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Day view:

"use client";/** HeroUI v3.2.6 derived work. Copyright NextUI Inc. Apache-2.0. Modified for StyleX and native Base UI Select. */import { Calendar } from "@lenso/ui";import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { CalendarHeader, CalendarGrid, DurationSelect, layout } from "./demo-parts";export function DayView() {  const [days, setDays] = useState(5);  return (    <div {...stylex.props(layout.controlsStack)}>      <DurationSelect unit="days" value={days} onChange={setDays} />      <Calendar aria-label="Day view" visibleDuration={{ days }}>        <CalendarHeader />        <CalendarGrid />      </Calendar>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Multiple selection:

"use client";/** HeroUI v3.2.6 derived work. Copyright NextUI Inc. Apache-2.0. Modified for StyleX. */import { Calendar } from "@lenso/ui";import type { DateValue } from "@internationalized/date";import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { CalendarNote } from "./demo-parts";
const styles = stylex.create({  root: { display: "flex", flexDirection: "column", alignItems: "center", gap: 16 },  description: { textAlign: "center" },});
export function MultipleSelection() {  const [value, setValue] = useState<readonly DateValue[]>([]);  return (    <div {...stylex.props(styles.root)}>      <Calendar aria-label="Event dates" selectionMode="multiple" value={value} onChange={setValue}>        <Calendar.Header>          <Calendar.Heading />          <Calendar.NavButton slot="previous" />          <Calendar.NavButton slot="next" />        </Calendar.Header>        <Calendar.Grid>          <Calendar.GridHeader>            {(day) => <Calendar.HeaderCell>{day}</Calendar.HeaderCell>}          </Calendar.GridHeader>          <Calendar.GridBody>{(date) => <Calendar.Cell date={date} />}</Calendar.GridBody>        </Calendar.Grid>      </Calendar>      <CalendarNote>        {value.length ? `${value.length} date(s) selected` : "Select multiple dates"}      </CalendarNote>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Autocomplete

Virtualizer support for large option lists, with docs, Storybook, and style fixes for popover sizing and listbox scroll height.

  • Virtualization: Wrap ListBox in React Aria's <Virtualizer> inside Autocomplete.Popover (#6642)
  • Popover sizing: Listbox caps at 320px with internal scrolling; search field stays fixed above the list

Virtualization:

"use client";/** HeroUI v3.2.6 adaptation. SPDX-License-Identifier: Apache-2.0 */import { Autocomplete } from "@lenso/ui";import { useRef, useState } from "react";import * as stylex from "@stylexjs/stylex";import { NativeAutocomplete, OptionContent, type Option } from "./_native";
const firstNames = [  "Emma",  "Liam",  "Olivia",  "Noah",  "Ava",  "James",  "Sophia",  "Oliver",  "Isabella",  "Lucas",  "Mia",  "Ethan",  "Charlotte",  "Mason",  "Amelia",  "Logan",  "Harper",  "Alexander",  "Ella",  "Benjamin",];const lastNames = [  "Smith",  "Johnson",  "Williams",  "Brown",  "Jones",  "Garcia",  "Miller",  "Davis",  "Rodriguez",  "Martinez",  "Anderson",  "Taylor",  "Thomas",  "Jackson",  "White",  "Harris",  "Clark",  "Lewis",  "Robinson",  "Walker",];const allUsers: Option[] = Array.from({ length: 1000 }, (_, index) => {  const first = firstNames[index % firstNames.length] ?? "";  const last = lastNames[Math.floor(index / firstNames.length) % lastNames.length] ?? "";  return {    id: String(index + 1),    name: `${first} ${last}`,    email: `${first.toLowerCase()}.${last.toLowerCase()}@acme.com`,  };});const virtualStyles = stylex.create({  field: { width: 300 },  list: { height: 300, overflowY: "auto", position: "relative", padding: 0 },  row: { height: 50, boxSizing: "border-box" },  spacer: (height: number) => ({ height }),});export function Virtualization() {  const { contains } = Autocomplete.useFilter({ sensitivity: "base" });  const [query, setQuery] = useState("");  const [scrollTop, setScrollTop] = useState(0);  const listRef = useRef<HTMLDivElement>(null);  const filtered = allUsers.filter(    (user) => contains(user.name, query) || contains(user.email ?? "", query),  );  const start = Math.max(0, Math.floor(scrollTop / 50) - 3);  const end = Math.min(filtered.length, start + 14);  const onHighlight = (item: Option | undefined) => {    const index = filtered.findIndex((user) => user.id === item?.id);    const element = listRef.current;    if (index < 0 || !element) return;    const top = index * 50;    if (top < element.scrollTop) element.scrollTop = top;    else if (top + 50 > element.scrollTop + 300) element.scrollTop = top - 250;    setScrollTop(element.scrollTop);  };  return (    <NativeAutocomplete      items={allUsers}      xstyle={virtualStyles.field}      filteredItems={filtered}      virtualized      onItemHighlighted={onHighlight}      label="User"      placeholder="Select a user"      searchLabel="Search users"      searchPlaceholder="Search users..."      inputValue={query}      onInputValueChange={(next) => {        setQuery(next);        setScrollTop(0);        if (listRef.current) listRef.current.scrollTop = 0;      }}      list={        <Autocomplete.List          ref={listRef}          xstyle={virtualStyles.list}          onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}        >          <div aria-hidden="true" {...stylex.props(virtualStyles.spacer(start * 50))} />          {filtered.slice(start, end).map((item, offset) => (            <Autocomplete.Item              key={item.id}              value={item}              index={start + offset}              aria-setsize={filtered.length}              aria-posinset={start + offset + 1}              xstyle={virtualStyles.row}            >              <OptionContent item={item} />              <Autocomplete.ItemIndicator />            </Autocomplete.Item>          ))}          <div            aria-hidden="true"            {...stylex.props(virtualStyles.spacer((filtered.length - end) * 50))}          />        </Autocomplete.List>      }    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Table.SortableColumnHeader

Table.SortableColumnHeader renders a sortable column label with an optional ascending/descending indicator. Use it inside a Table.Column render prop and forward sortDirection (#6588).

<Table.Column allowsSorting>  {({sortDirection}) => (    <Table.SortableColumnHeader sortDirection={sortDirection}>      Name    </Table.SortableColumnHeader>  )}</Table.Column>
  • Default indicator: Chevron appears when a sort direction exists
  • Custom indicator: Pass indicator, or hide it with showIndicator={false}
  • Style slots: .table__sortable-column-header + .table__sortable-column-indicator
"use client";// HeroUI v3.2.6 sorting adaptation (Apache-2.0).import { useState } from "react";import { Table, type SortDescriptor } from "@lenso/ui";import { Headers, Rows, styles, users, type User } from "./data";export function Sorting() {  const [sort, setSort] = useState<SortDescriptor>({ column: "name", direction: "ascending" });  const sorted = users    .slice(0, 5)    .sort(      (a, b) =>        String(a[sort.column as keyof User]).localeCompare(String(b[sort.column as keyof User])) *        (sort.direction === "ascending" ? 1 : -1),    );  return (    <Table>      <Table.ScrollContainer>        <Table.Content          aria-label="Sortable table"          xstyle={styles.content}          sortDescriptor={sort}          onSortChange={setSort}        >          <Headers sorting />          <Table.Body>            <Rows items={sorted} />          </Table.Body>        </Table.Content>      </Table.ScrollContainer>    </Table>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Tooltip delay theme variables

Tooltip reads default show and hide delays from theme CSS variables (#6617):

  • --tooltip-delay — delay before showing a tooltip (default: 1500ms)
  • --tooltip-close-delay — delay before hiding a tooltip (default: 500ms)

Override them globally:

:root {  --tooltip-delay: 700ms;  --tooltip-close-delay: 0ms;}

Individual delay and closeDelay props still override these values on specific tooltips.

Behavior change: With the default HeroUI theme, tooltip delays now default to 1500ms / 500ms instead of the previous React Aria defaults of 700ms / 0ms. Set the CSS variables or props explicitly to preserve the old timing.

Component Fixes

  • Toast: ViewTransition updates are serialized in the toast queue, preventing skipped transitions and AbortError rejections when toast.promise() swaps the loading toast for success/error feedback (#6511).
  • Fieldset: disabled propagates through React Aria Button, CheckboxGroup, Link, RadioGroup, Slider, ToggleButton, and ToggleButtonGroup contexts (#6596).
  • Autocomplete: Popover content is wrapped in a React Aria Dialog, so opening the popover no longer leaves a stray focus ring on the listbox (#6627).
  • Tooltip: The trigger uses the useFocusable hook instead of the <Focusable> wrapper, avoiding a false-positive "child must be focusable" warning when a tooltip is mounted inside an inert subtree (e.g. behind an open Drawer/Modal) (#6628).

Style Fixes

  • Modal / AlertDialog: scroll-inside dialogs cap height with max-h-full min-h-0 so the body scrolls instead of overflowing (#6597).
  • ScrollShadow: Fade masks reserve space for visible native scrollbars via --scroll-shadow-scrollbar-size (#6598).
  • Table RTL: Column separators and resize handles use logical end-0 positioning in RTL (#6606).
  • Link: Drops the hardcoded text-sm so links inherit font size from their parent, and .link__icon scales relative to text with size-[0.75em] instead of a fixed size-2 (#6621).
  • DatePicker / DateRangePicker: Calendar popovers use min-w-(--trigger-width) instead of max-w-(--trigger-width) so the calendar is at least as wide as the trigger and no longer clips horizontally (#6622).
  • Table: Secondary header borders and rounded corners render correctly when the table is wrapped in React Aria's <Virtualizer> — column selectors no longer treat every virtualized column as both first and last child (#6624).
  • Autocomplete: The popover is constrained to the trigger width and the listbox caps its height at 320px with internal scrolling, so virtualized lists no longer overflow the popover (#6642).
  • ListBox: Replaces flex flex-col gap with block flow + sibling margins so React Aria's Virtualizer content height is not collapsed by flex-shrink — fixes scrollbar thumb resizing during virtualized scroll (#6636).
  • InputGroup / NumberField / SearchField: Browser autofill highlight lifts onto the group shell so prefix, suffix, and increment/decrement slots share the rounded highlight (#6625).
  • Spinner: Uses inline-flex with shrink-0 (instead of relative) so the spin animation renders correctly in non-flex layouts, and honors motion-reduce (#6644).
  • Toast: Close button uses -top-1 -right-1 on all breakpoints to align with container padding (#6574).

Dependencies

  • React Aria Components: 1.17.0 → 1.18.0 (#6586). 1.18 adds the *Field + *Button composition adopted by the toggles, CalendarHeading, and isDateUnavailable(date, anchorDate).
  • @internationalized/date: 3.12.1 → 3.12.2
  • React Aria / Stately helpers: @react-aria/*, @react-stately/*, and @react-types/shared patch updates

⚠️ Breaking Changes

Radio, Checkbox & Switch: explicit *.Content composition

These toggles now use React Aria's *Field + *Button composition under the hood. X.Content is now the clickable label (React Aria's *Button). Three things change:

  • X.Control moves inside X.Content — they used to be siblings.
  • The label is plain text inside X.Content — X.Content renders the <label> element, so don't nest a <Label> component (nested <label> elements are invalid HTML). For a standalone Label, place it outside and link it with htmlFor + the toggle id.
  • Description/FieldError move out as siblings of X.Content, so they're exposed via aria-describedby instead of being folded into the accessible name.

Checkbox

// v3.1<Checkbox>  <Checkbox.Control>    <Checkbox.Indicator />  </Checkbox.Control>  <Checkbox.Content>    <Label>Accept terms</Label>    <Description>You agree to our terms</Description>  </Checkbox.Content></Checkbox>
// v3.2<Checkbox>  <Checkbox.Content>    <Checkbox.Control>      <Checkbox.Indicator />    </Checkbox.Control>    Accept terms  </Checkbox.Content>  <Description>You agree to our terms</Description></Checkbox>

Radio

// v3.1<Radio value="a">  <Radio.Control>    <Radio.Indicator />  </Radio.Control>  <Radio.Content>    <Label>Option A</Label>  </Radio.Content></Radio>
// v3.2<Radio value="a">  <Radio.Content>    <Radio.Control>      <Radio.Indicator />    </Radio.Control>    Option A  </Radio.Content></Radio>

Switch

// v3.1<Switch>  <Switch.Control>    <Switch.Thumb />  </Switch.Control>  <Switch.Content>    <Label>Enable notifications</Label>  </Switch.Content></Switch>
// v3.2<Switch>  <Switch.Content>    <Switch.Control>      <Switch.Thumb />    </Switch.Control>    Enable notifications  </Switch.Content></Switch>

Migration summary

v3.1v3.2
X.Control and X.Content are siblingsX.Control nests inside X.Content
X.Content is a layout <div> wrapping Label + help textX.Content is the clickable <label> wrapping X.Control + label text
Label provided via <Label> inside X.ContentLabel is plain text inside X.Content (no nested <Label>)
Description / FieldError inside X.ContentDescription / FieldError as siblings of X.Content

External label — to use a standalone Label, place it outside the toggle and link it with htmlFor + the toggle id:

<div className="flex items-center gap-3">  <Checkbox id="terms">    <Checkbox.Content>      <Checkbox.Control>        <Checkbox.Indicator />      </Checkbox.Control>    </Checkbox.Content>  </Checkbox>  <Label htmlFor="terms">Accept terms</Label></div>

Control-only checkboxes and switches (no label, e.g. table row selection or icon switches) still need X.Content as the clickable wrapper. Wrap Checkbox.Control / Switch.Control in X.Content, omit the label, and pass an aria-label on the root.

Full per-component guides: Checkbox, Checkbox Group, Radio, Radio Group, and Switch.

Contributors

Thanks to everyone who contributed to this release!

HeroUI contributors