Skip to content
Lenso UI

v3.0.0-beta.8

3 new components (Badge, Pagination, Table), DateField improvements, and key API/style fixes.

March 2, 2026

This release adds three new components: Badge, Pagination, and Table, plus new InputContainer composition APIs for DateField and TimeField.

⚠️ Breaking changes: TextField CSS classes were renamed from .text-field to .textfield.

HeroUI v3 Beta 8

Installation

Update to the latest version:

npm i @lenso/tokens@beta @lenso/ui@beta

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

What's New

New Components

  • Badge: Compact status + count indicator with color, variant, placement, and size options. (Docs)
  • Pagination: Compound pagination primitives with summary, ellipsis, and previous/next controls. (Docs)
  • Table: Data table primitives with sorting, selection, resizing, async loading, and footer composition. (Docs)

Badge

New badge primitives for counters, labels, and anchored overlays with Badge.Anchor and Badge.Label.

"use client";// oxlint-disable jsx-a11y/prefer-tag-over-role -- The CSS-painted status dot has no image resource for an HTML img.// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { Avatar, Badge } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { s } from "../card/display.stylex";
export function BadgeBasic() {  return (    <div {...stylex.props(s.row6)}>      <Badge.Anchor>        <Avatar>          <Avatar.Image            alt="John Doe"            src="https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg"          />          <Avatar.Fallback>JD</Avatar.Fallback>        </Avatar>        <Badge color="danger" size="sm">          5        </Badge>      </Badge.Anchor>      <Badge.Anchor>        <Avatar>          <Avatar.Image            alt="Alex Brown"            src="https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/orange.jpg"          />          <Avatar.Fallback>AB</Avatar.Fallback>        </Avatar>        <Badge color="accent" size="sm">          New        </Badge>      </Badge.Anchor>      <Badge.Anchor>        <Avatar>          <Avatar.Image            alt="Chris Davis"            src="https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg"          />          <Avatar.Fallback>CD</Avatar.Fallback>        </Avatar>        <Badge role="img" aria-label="Online" color="success" placement="bottom-right" size="sm" />      </Badge.Anchor>    </div>  );}

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

Pagination

New navigation component built with composable parts (Root, Content, Item, Link, Previous, Next, Summary, Ellipsis).

"use client";export { PaginationWithEllipsis } from "./source";

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

Table

Compound data table on React Aria with sortable columns, row selection, custom cells, load-more sentinel rows, and resizable columns.

"use client";
// Adapted from HeroUI v3.2.6 table-basic (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Table } from "@lenso/ui";
const rows = [  { key: "kate", name: "Kate Moore", role: "CEO", status: "Active", email: "[email protected]" },  { key: "john", name: "John Smith", role: "CTO", status: "Active", email: "[email protected]" },  { key: "sara", name: "Sara Johnson", role: "CMO", status: "On Leave", email: "[email protected]" },  {    key: "michael",    name: "Michael Brown",    role: "CFO",    status: "Active",    email: "[email protected]",  },] as const;const styles = stylex.create({ content: { minWidth: 600 } });
export function Basic() {  return (    <Table>      <Table.ScrollContainer>        <Table.Content aria-label="Team members" xstyle={styles.content}>          <Table.Header>            <Table.Column columnKey="name">Name</Table.Column>            <Table.Column columnKey="role">Role</Table.Column>            <Table.Column columnKey="status">Status</Table.Column>            <Table.Column columnKey="email">Email</Table.Column>          </Table.Header>          <Table.Body>            <Table.Collection items={rows}>              {(row) => (                <Table.Row itemKey={row.key}>                  <Table.Cell>{row.name}</Table.Cell>                  <Table.Cell>{row.role}</Table.Cell>                  <Table.Cell>{row.status}</Table.Cell>                  <Table.Cell>{row.email}</Table.Cell>                </Table.Row>              )}            </Table.Collection>          </Table.Body>        </Table.Content>      </Table.ScrollContainer>    </Table>  );}

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

Custom Cells:

"use client";// HeroUI v3.2.6 custom-cells adaptation (Apache-2.0).import { useState, type Key } from "react";import * as stylex from "@stylexjs/stylex";import { Icon } from "@iconify/react";import { Avatar, Button, Table, type SortDescriptor } from "@lenso/ui";import { Status, styles, users, type User } from "./data";const workers = users.slice(0, 5).map((user, index) => ({  ...user,  key: [4586932, 5273849, 7492836, 8293746, 1234567][index]!,  role: [    "Chief Executive Officer",    "Chief Technology Officer",    "Chief Marketing Officer",    "Chief Financial Officer",    "Product Manager",  ][index]!,  color: ["red", "green", "blue", "purple", "orange"][index],}));export function CustomCells() {  const [selected, setSelected] = useState<Set<Key>>(new Set());  const [sort, setSort] = useState<SortDescriptor>({ column: "name", direction: "ascending" });  const sorted = [...workers].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="Table with custom cells"          xstyle={styles.customCells}          selectionMode="multiple"          selectedKeys={selected}          onSelectionChange={setSelected}          sortDescriptor={sort}          onSortChange={setSort}        >          <Table.Header>            <Table.Column columnKey="selection" width={48}>              <Table.SelectionCheckbox />            </Table.Column>            {[              ["key", "Worker ID"],              ["name", "Member"],              ["role", "Role"],              ["status", "Status"],            ].map(([key, title]) => (              <Table.Column key={key} columnKey={key!} allowsSorting>                {({ sortDirection }) => (                  <Table.SortableColumnHeader sortDirection={sortDirection}>                    {title}                  </Table.SortableColumnHeader>                )}              </Table.Column>            ))}            <Table.Column columnKey="actions">Actions</Table.Column>          </Table.Header>          <Table.Body>            {sorted.map((user) => (              <Table.Row key={user.key} itemKey={user.key}>                <Table.Cell>                  <Table.SelectionCheckbox aria-label={`Select ${user.name}`} />                </Table.Cell>                <Table.Cell>                  <div {...stylex.props(styles.row)}>                    #{user.key}                    <Button                      isIconOnly                      aria-label={`Copy ID #${user.key}`}                      size="sm"                      variant="ghost"                    >                      <Icon icon="gravity-ui:copy" width={16} aria-hidden="true" />                    </Button>                  </div>                </Table.Cell>                <Table.Cell>                  <div {...stylex.props(styles.row)}>                    <Avatar size="sm">                      <Avatar.Image                        alt={user.name}                        src={`https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/${user.color}.jpg`}                      />                      <Avatar.Fallback>                        {user.name                          .split(" ")                          .map((name) => name[0])                          .join("")}                      </Avatar.Fallback>                    </Avatar>                    <div {...stylex.props(styles.detail)}>                      <span>{user.name}</span>                      <span {...stylex.props(styles.muted)}>{user.email}</span>                    </div>                  </div>                </Table.Cell>                <Table.Cell>{user.role}</Table.Cell>                <Table.Cell>                  <Status status={user.status} />                </Table.Cell>                <Table.Cell>                  <div {...stylex.props(styles.row)}>                    {(["View", "Edit", "Delete"] as const).map((action, index) => (                      <Button                        key={action}                        isIconOnly                        aria-label={`${action} ${user.name}`}                        size="sm"                        variant={action === "Delete" ? "danger-soft" : "tertiary"}                      >                        <Icon                          icon={`gravity-ui:${["eye", "pencil", "trash-bin"][index]}`}                          width={16}                          aria-hidden="true"                        />                      </Button>                    ))}                  </div>                </Table.Cell>              </Table.Row>            ))}          </Table.Body>        </Table.Content>      </Table.ScrollContainer>    </Table>  );}

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

Pagination:

"use client";// HeroUI v3.2.6 pagination adaptation (Apache-2.0).import { useState } from "react";import { Table } from "@lenso/ui";import { Headers, Pages, Rows, styles, users } from "./data";export function PaginationDemo() {  const [page, setPage] = useState(1);  return (    <Table>      <Table.ScrollContainer>        <Table.Content aria-label="Table with pagination" xstyle={styles.content}>          <Headers />          <Table.Body>            <Rows items={users.slice((page - 1) * 4, page * 4)} />          </Table.Body>        </Table.Content>      </Table.ScrollContainer>      <Table.Footer>        <Pages page={page} count={2} total={8} setPage={setPage} />      </Table.Footer>    </Table>  );}

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

Empty State:

"use client";// HeroUI v3.2.6 empty-state adaptation (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Icon } from "@iconify/react";import { EmptyState, Table } from "@lenso/ui";import { Headers, styles } from "./data";const empty = stylex.create({  root: { minHeight: 200 },  content: {    minHeight: 140,    display: "flex",    flexDirection: "column",    alignItems: "center",    justifyContent: "center",    gap: 16,    textAlign: "center",    color: "var(--muted)",  },});export function EmptyStateDemo() {  return (    <Table xstyle={empty.root}>      <Table.ScrollContainer>        <Table.Content aria-label="Empty table" xstyle={styles.content}>          <Headers />          <Table.Body>            <tr>              <td colSpan={4}>                <EmptyState xstyle={empty.content}>                  <Icon icon="gravity-ui:tray" width={24} aria-hidden="true" />                  <span>No results found</span>                </EmptyState>              </td>            </tr>          </Table.Body>        </Table.Content>      </Table.ScrollContainer>    </Table>  );}

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

Component + API Improvements

DateField and TimeField Enhancements

DateField and TimeField now expose InputContainer to wrap input segments between prefix and suffix content.

Before:

<DateField.Group>  <DateField.Prefix>...</DateField.Prefix>  <DateField.Input>    {(segment) => <DateField.Segment segment={segment} />}  </DateField.Input>  <DateField.Suffix>...</DateField.Suffix></DateField.Group>

After:

<DateField.Group>  <DateField.Prefix>...</DateField.Prefix>  <DateField.InputContainer>    <DateField.Input>      {(segment) => <DateField.Segment segment={segment} />}    </DateField.Input>  </DateField.InputContainer>  <DateField.Suffix>...</DateField.Suffix></DateField.Group>
"use client";/** Adapted from HeroUI v3.2.6 release-input-container.json. Copyright NextUI Inc. Apache-2.0. */import { DateRangePicker, TimeField } from "@lenso/ui";import { getLocalTimeZone, parseZonedDateTime } from "@internationalized/date";import * as stylex from "@stylexjs/stylex";import { formatStyles } from "../date-picker/format-controls";import { PickerCalendar, RangeInput } from "./parts";
export function InputContainer() {  const localTimeZone = getLocalTimeZone();  const defaultValue = {    start: parseZonedDateTime(`2026-02-03T08:45:00[${localTimeZone}]`),    end: parseZonedDateTime(`2026-02-10T18:45:00[${localTimeZone}]`),  };  return (    <DateRangePicker      shouldForceLeadingZeros      xstyle={formatStyles.releaseField}      defaultValue={defaultValue}      granularity="second"      hourCycle={12}    >      {({ state }) => (        <>          <DateRangePicker.Label>Date range</DateRangePicker.Label>          <RangeInput container />          <DateRangePicker.Popover xstyle={formatStyles.releasePopover}>            <PickerCalendar />            <div {...stylex.props(formatStyles.times)}>              <div {...stylex.props(formatStyles.timeRow)}>                <span {...stylex.props(formatStyles.label)}>Start Time</span>                <TimeField                  shouldForceLeadingZeros                  aria-label="Start Time"                  granularity="second"                  hourCycle={12}                  value={state.timeRange?.start ?? null}                  onChange={(value) => state.setTime("start", value)}                >                  <TimeField.Group variant="secondary">                    <TimeField.Input>                      {(segment) => <TimeField.Segment segment={segment} />}                    </TimeField.Input>                  </TimeField.Group>                </TimeField>              </div>              <div {...stylex.props(formatStyles.timeRow)}>                <span {...stylex.props(formatStyles.label)}>End Time</span>                <TimeField                  shouldForceLeadingZeros                  aria-label="End Time"                  granularity="second"                  hourCycle={12}                  value={state.timeRange?.end ?? null}                  onChange={(value) => state.setTime("end", value)}                >                  <TimeField.Group variant="secondary">                    <TimeField.Input>                      {(segment) => <TimeField.Segment segment={segment} />}                    </TimeField.Input>                  </TimeField.Group>                </TimeField>              </div>            </div>          </DateRangePicker.Popover>        </>      )}    </DateRangePicker>  );}

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

⚠️ Breaking Changes

TextField Class + Path Renames

TextField style naming was normalized to avoid conflicts with Tailwind's text-* utility prefix.

ComponentOld Class NameNew Class NameNotes
TextField Root.text-field.textfieldRoot class renamed
TextField Full Width.text-field--full-width.textfield--full-widthModifier class renamed

Additional renames in the same change:

  • Style file: text-field.css -> textfield.css
  • Styles export path: @lenso/tokens/src/components/text-field -> @lenso/tokens/src/components/textfield

Style Fixes

  • RangeCalendar: Added rounded corners to calendar cells for range selection visuals (#6270)

Bug Fixes

  • Added required-state red asterisk behavior for DatePicker and DateRangePicker by propagating data-required from isRequired (#6270)
  • Fixed missing invalid trigger styling in Autocomplete and Select by scoping invalid styles to root state (#6270)
  • Updated TextField docs/demo references to the new textfield-* demo keys and source/style paths (#6270)

Contributors

Thanks to everyone who contributed to this release!

HeroUI contributors