Skip to content
Lenso UI

Modal

Dialog overlay for focused user interactions and important content

Usage

import { Modal } from "@lenso/ui";
"use client";
// Adapted from HeroUI v3.2.6 modal-default (Apache-2.0).import { Rocket } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  popup: { maxWidth: 360 },  icon: { backgroundColor: "var(--default)", color: "var(--foreground)" },  rocket: { width: 20, height: 20 },  continue: { width: "100%" },});
export function Default() {  return (    <Modal>      <Modal.Trigger render={<Button variant="secondary" />}>Open Modal</Modal.Trigger>      <Modal.Portal>        <Modal.Backdrop />        <Modal.Viewport>          <Modal.Popup xstyle={styles.popup}>            <Modal.Close aria-label="Close dialog" />            <Modal.Header>              <Modal.Icon xstyle={styles.icon}>                <Rocket {...stylex.props(styles.rocket)} />              </Modal.Icon>              <Modal.Title>Welcome to HeroUI</Modal.Title>            </Modal.Header>            <Modal.Body>              <Modal.Description>                A beautiful, fast, and modern React UI library for building accessible and                customizable web applications with ease.              </Modal.Description>            </Modal.Body>            <Modal.Footer>              <Modal.Close render={<Button xstyle={styles.continue} />}>Continue</Modal.Close>            </Modal.Footer>          </Modal.Popup>        </Modal.Viewport>      </Modal.Portal>    </Modal>  );}

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

Anatomy

import {Modal, Button} from "@lenso/ui";
export default () => (  <Modal>    <Button>Open Modal</Button>    <Modal.Backdrop>      <Modal.Container>        <Modal.Dialog>          <Modal.CloseTrigger /> {/* Optional: Close button */}          <Modal.Header>            <Modal.Icon /> {/* Optional: Icon */}            <Modal.Heading />          </Modal.Header>          <Modal.Body />          <Modal.Footer />        </Modal.Dialog>      </Modal.Container>    </Modal.Backdrop>  </Modal>);

Examples

Sizes

"use client";// Adapted from HeroUI v3.2.6 modal-sizes (Apache-2.0).import { Rocket } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  row: { display: "flex", flexWrap: "wrap", gap: 16 },  icon: { backgroundColor: "var(--default)", color: "var(--foreground)" },  rocket: { width: 20, height: 20 },  action: { width: "100%" },});
export function Sizes() {  const sizes = ["xs", "sm", "md", "lg", "cover", "full"] as const;  return (    <div {...stylex.props(styles.row)}>      {sizes.map((size) => (        <Modal key={size}>          <Modal.Trigger render={<Button variant="secondary" />}>            {size.charAt(0).toUpperCase() + size.slice(1)}          </Modal.Trigger>          <Modal.Portal>            <Modal.Backdrop />            <Modal.Viewport>              <Modal.Popup size={size}>                <Modal.Close aria-label="Close dialog" />                <Modal.Header>                  <Modal.Icon xstyle={styles.icon}>                    <Rocket {...stylex.props(styles.rocket)} />                  </Modal.Icon>                  <Modal.Title>Size: {size.charAt(0).toUpperCase() + size.slice(1)}</Modal.Title>                </Modal.Header>                <Modal.Body>                  <Modal.Description>                    {size === "cover" ? (                      <>                        This modal uses the <code>cover</code> size variant. It spans the full                        screen with margins: 16px on mobile and 40px on desktop. Maintains rounded                        corners and standard padding. Perfect for cover-style content that needs                        maximum width while preserving modal aesthetics.                      </>                    ) : size === "full" ? (                      <>                        This modal uses the <code>full</code> size variant. It occupies the entire                        viewport without any margins, rounded corners, or shadows, creating a true                        fullscreen experience. Ideal for immersive content or full-page                        interactions.                      </>                    ) : (                      <>                        This modal uses the <code>{size}</code> size variant. On mobile devices, all                        sizes adapt to near full-width for optimal viewing. On desktop, each size                        provides a different maximum width to suit various content needs.                      </>                    )}                  </Modal.Description>                </Modal.Body>                <Modal.Footer>                  <Modal.Close render={<Button variant="secondary" />}>Cancel</Modal.Close>                  <Modal.Close render={<Button />}>Confirm</Modal.Close>                </Modal.Footer>              </Modal.Popup>            </Modal.Viewport>          </Modal.Portal>        </Modal>      ))}    </div>  );}

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

Placement

"use client";// Adapted from HeroUI v3.2.6 modal-placements (Apache-2.0).import { Rocket } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  row: { display: "flex", flexWrap: "wrap", gap: 16 },  popup: { maxWidth: 360 },  icon: { backgroundColor: "var(--default)", color: "var(--foreground)" },  rocket: { width: 20, height: 20 },  action: { width: "100%" },});
export function Placements() {  const placements = ["auto", "top", "center", "bottom"] as const;  return (    <div {...stylex.props(styles.row)}>      {placements.map((placement) => (        <Modal key={placement}>          <Modal.Trigger render={<Button variant="secondary" />}>            {placement.charAt(0).toUpperCase() + placement.slice(1)}          </Modal.Trigger>          <Modal.Portal>            <Modal.Backdrop />            <Modal.Viewport>              <Modal.Popup placement={placement} xstyle={styles.popup}>                <Modal.Close aria-label="Close dialog" />                <Modal.Header>                  <Modal.Icon xstyle={styles.icon}>                    <Rocket {...stylex.props(styles.rocket)} />                  </Modal.Icon>                  <Modal.Title>                    Placement: {placement.charAt(0).toUpperCase() + placement.slice(1)}                  </Modal.Title>                </Modal.Header>                <Modal.Body>                  <Modal.Description>                    This modal uses the <code>{placement}</code> placement option. Try different                    placements to see how the modal positions itself on the screen.                  </Modal.Description>                </Modal.Body>                <Modal.Footer>                  <Modal.Close render={<Button xstyle={styles.action} />}>Continue</Modal.Close>                </Modal.Footer>              </Modal.Popup>            </Modal.Viewport>          </Modal.Portal>        </Modal>      ))}    </div>  );}

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

Scroll Behavior

"use client";// Adapted from HeroUI v3.2.6 modal-scroll-comparison (Apache-2.0).import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  column: { display: "flex", flexDirection: "column", gap: 16 },  options: { display: "flex", gap: 16, borderWidth: 0, padding: 0 },  popup: { maxWidth: 360 },  caption: { fontSize: 14, lineHeight: "20px", color: "var(--muted)" },  paragraph: { marginBottom: 12 },});
export function ScrollComparison() {  const [scroll, setScroll] = useState<"inside" | "outside">("inside");  return (    <div {...stylex.props(styles.column)}>      <fieldset {...stylex.props(styles.options)} aria-label="Scroll behavior">        {(["inside", "outside"] as const).map((value) => (          <label key={value}>            <input              type="radio"              name="modal-scroll"              value={value}              checked={scroll === value}              onChange={() => setScroll(value)}            />            {value === "inside" ? "Inside" : "Outside"}          </label>        ))}      </fieldset>      <Modal scroll={scroll}>        <Modal.Trigger render={<Button variant="secondary" />}>          Open Modal ({scroll.charAt(0).toUpperCase() + scroll.slice(1)})        </Modal.Trigger>        <Modal.Portal>          <Modal.Backdrop />          <Modal.Viewport>            <Modal.Popup xstyle={styles.popup}>              <Modal.Header>                <Modal.Title>                  Scroll: {scroll.charAt(0).toUpperCase() + scroll.slice(1)}                </Modal.Title>                <Modal.Description xstyle={styles.caption}>                  Compare scroll behaviors - inside keeps content scrollable within the modal,                  outside allows page scrolling                </Modal.Description>              </Modal.Header>              <Modal.Body>                {Array.from({ length: 30 }).map((_, i) => (                  <p key={i} {...stylex.props(styles.paragraph)}>                    Paragraph {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit.                    Nullam pulvinar risus non risus hendrerit venenatis. Pellentesque sit amet                    hendrerit risus, sed porttitor quam.                  </p>                ))}              </Modal.Body>              <Modal.Footer>                <Modal.Close render={<Button variant="secondary" />}>Cancel</Modal.Close>                <Modal.Close render={<Button />}>Confirm</Modal.Close>              </Modal.Footer>              <Modal.Close aria-label="Close dialog" />            </Modal.Popup>          </Modal.Viewport>        </Modal.Portal>      </Modal>    </div>  );}

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

Controlled State

"use client";// Adapted from HeroUI v3.2.6 modal-controlled (Apache-2.0).// The second controller uses a reducer instead of the React Aria overlay-state hook.import { CircleCheck } from "@gravity-ui/icons";import { useReducer, useState } from "react";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  column: { display: "flex", maxWidth: 448, flexDirection: "column", gap: 32 },  section: { display: "flex", flexDirection: "column", gap: 12 },  heading: { fontSize: 18, fontWeight: 600, color: "var(--foreground)" },  caption: { fontSize: 14, lineHeight: 1.625, textWrap: "pretty", color: "var(--muted)" },  panel: {    display: "flex",    flexDirection: "column",    alignItems: "flex-start",    gap: 12,    borderRadius: 16,    backgroundColor: "var(--surface)",    padding: 16,    boxShadow: "var(--shadow-sm)",  },  status: { fontSize: 12, color: "var(--muted)" },  value: { fontFamily: "monospace", fontWeight: 500, color: "var(--foreground)" },  actions: { display: "flex", gap: 8 },  popup: { maxWidth: 360 },  accent: { backgroundColor: "var(--accent-soft)", color: "var(--accent-soft-foreground)" },  success: { backgroundColor: "var(--success-soft)", color: "var(--success-soft-foreground)" },  circle: { width: 20, height: 20 },});
export function Controlled() {  const [isOpen, setIsOpen] = useState(false);  const [reducerOpen, dispatch] = useReducer(    (open: boolean, action: "open" | "close" | "toggle") =>      action === "toggle" ? !open : action === "open",    false,  );  return (    <div {...stylex.props(styles.column)}>      {([false, true] as const).map((reducer) => {        const open = reducer ? reducerOpen : isOpen;        const setOpen = (value: boolean) =>          reducer ? dispatch(value ? "open" : "close") : setIsOpen(value);        return (          <div key={String(reducer)} {...stylex.props(styles.section)}>            <h3 {...stylex.props(styles.heading)}>              {reducer ? "With useReducer()" : "With React.useState()"}            </h3>            <p {...stylex.props(styles.caption)}>              {reducer ? (                "Use a reducer to manage open, close, and toggle actions."              ) : (                <>                  Control the modal using React&apos;s <code>useState</code> hook for simple state                  management. Perfect for basic use cases.                </>              )}            </p>            <div {...stylex.props(styles.panel)}>              <p {...stylex.props(styles.status)}>                Status: <span {...stylex.props(styles.value)}>{open ? "open" : "closed"}</span>              </p>              <div {...stylex.props(styles.actions)}>                <Button size="sm" variant="secondary" onClick={() => setOpen(true)}>                  Open Modal                </Button>                <Button                  size="sm"                  variant="tertiary"                  onClick={() => (reducer ? dispatch("toggle") : setIsOpen((value) => !value))}                >                  Toggle                </Button>              </div>            </div>            <Modal open={open} onOpenChange={setOpen}>              <Modal.Portal>                <Modal.Backdrop />                <Modal.Viewport>                  <Modal.Popup xstyle={styles.popup}>                    <Modal.Close aria-label="Close dialog" />                    <Modal.Header>                      <Modal.Icon xstyle={reducer ? styles.success : styles.accent}>                        <CircleCheck {...stylex.props(styles.circle)} />                      </Modal.Icon>                      <Modal.Title>                        Controlled with {reducer ? "useReducer()" : "useState()"}                      </Modal.Title>                    </Modal.Header>                    <Modal.Body>                      <Modal.Description>                        {reducer ? (                          "The reducer handles open, close, and toggle actions while the native Root owns focus and dismissal."                        ) : (                          <>                            This modal is controlled by React&apos;s <code>useState</code> hook.                            Pass <code>open</code> and <code>onOpenChange</code> props to manage the                            modal state externally.                          </>                        )}                      </Modal.Description>                    </Modal.Body>                    <Modal.Footer>                      <Modal.Close render={<Button variant="secondary" />}>Cancel</Modal.Close>                      <Modal.Close render={<Button />}>Confirm</Modal.Close>                    </Modal.Footer>                  </Modal.Popup>                </Modal.Viewport>              </Modal.Portal>            </Modal>          </div>        );      })}    </div>  );}

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

With Form

"use client";// Adapted from HeroUI v3.2.6 modal-with-form (Apache-2.0).import { Envelope } from "@gravity-ui/icons";import { useId, useState } from "react";import * as stylex from "@stylexjs/stylex";import { Button, Input, Label, Modal, Surface, TextField } from "@lenso/ui";
const styles = stylex.create({  popup: { maxWidth: 448 },  icon: { backgroundColor: "var(--accent-soft)", color: "var(--accent-soft-foreground)" },  envelope: { width: 20, height: 20 },  caption: { marginTop: 6, fontSize: 14, lineHeight: "20px", color: "var(--muted)" },  body: { padding: 24 },  form: { display: "flex", flexDirection: "column", gap: 16 },  field: { width: "100%" },});
export function WithForm() {  const formId = useId();  const [open, setOpen] = useState(false);  return (    <Modal open={open} onOpenChange={setOpen}>      <Modal.Trigger render={<Button variant="secondary" />}>Open Contact Form</Modal.Trigger>      <Modal.Portal>        <Modal.Backdrop />        <Modal.Viewport>          <Modal.Popup placement="auto" xstyle={styles.popup}>            <Modal.Close aria-label="Close dialog" />            <Modal.Header>              <Modal.Icon xstyle={styles.icon}>                <Envelope {...stylex.props(styles.envelope)} />              </Modal.Icon>              <Modal.Title>Contact Us</Modal.Title>              <Modal.Description xstyle={styles.caption}>                Fill out the form below and we&apos;ll get back to you. The modal adapts                automatically when the keyboard appears on mobile.              </Modal.Description>            </Modal.Header>            <Modal.Body xstyle={styles.body}>              <Surface variant="default">                <form                  id={formId}                  {...stylex.props(styles.form)}                  onSubmit={(event) => {                    event.preventDefault();                    setOpen(false);                  }}                >                  <TextField name="name" xstyle={styles.field}>                    <Label>Name</Label>                    <Input type="text" variant="secondary" placeholder="Enter your name" />                  </TextField>                  <TextField name="email" xstyle={styles.field}>                    <Label>Email</Label>                    <Input type="email" variant="secondary" placeholder="Enter your email" />                  </TextField>                  <TextField name="phone" xstyle={styles.field}>                    <Label>Phone</Label>                    <Input type="tel" variant="secondary" placeholder="Enter your phone number" />                  </TextField>                  <TextField name="company" xstyle={styles.field}>                    <Label>Company</Label>                    <Input variant="secondary" placeholder="Enter your company name" />                  </TextField>                  <TextField name="message" xstyle={styles.field}>                    <Label>Message</Label>                    <Input variant="secondary" placeholder="Enter your message" />                  </TextField>                </form>              </Surface>            </Modal.Body>            <Modal.Footer>              <Modal.Close render={<Button variant="secondary" />}>Cancel</Modal.Close>              <Button type="submit" form={formId}>                Send Message              </Button>            </Modal.Footer>          </Modal.Popup>        </Modal.Viewport>      </Modal.Portal>    </Modal>  );}

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

Custom Trigger

"use client";// Adapted from HeroUI v3.2.6 modal-custom-trigger (Apache-2.0).import { Gear } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  trigger: {    display: "flex",    alignItems: "center",    gap: 12,    borderRadius: 16,    backgroundColor: { default: "var(--surface)", ":hover": "var(--surface-secondary)" },    padding: 16,    boxShadow: "var(--shadow-xs)",    userSelect: "none",  },  gearBox: {    display: "flex",    width: 48,    height: 48,    flexShrink: 0,    alignItems: "center",    justifyContent: "center",    borderRadius: 12,    backgroundColor: "var(--accent-soft)",    color: "var(--accent-soft-foreground)",  },  copy: { display: "flex", flex: 1, flexDirection: "column", gap: 2, textAlign: "start" },  heading: { fontSize: 14, fontWeight: 600 },  caption: { fontSize: 12, color: "var(--muted)" },  popup: { maxWidth: 360 },  icon: { backgroundColor: "var(--accent-soft)", color: "var(--accent-soft-foreground)" },  largeGear: { width: 24, height: 24 },  gear: { width: 20, height: 20 },});
export function CustomTrigger() {  return (    <Modal>      <Modal.Trigger xstyle={styles.trigger}>        <div {...stylex.props(styles.gearBox)}>          <Gear {...stylex.props(styles.largeGear)} />        </div>        <div {...stylex.props(styles.copy)}>          <p {...stylex.props(styles.heading)}>Settings</p>          <p {...stylex.props(styles.caption)}>Manage your preferences</p>        </div>      </Modal.Trigger>      <Modal.Portal>        <Modal.Backdrop />        <Modal.Viewport>          <Modal.Popup xstyle={styles.popup}>            <Modal.Close aria-label="Close dialog" />            <Modal.Header>              <Modal.Icon xstyle={styles.icon}>                <Gear {...stylex.props(styles.gear)} />              </Modal.Icon>              <Modal.Title>Settings</Modal.Title>            </Modal.Header>            <Modal.Body>              <Modal.Description>                Use <code>Modal.Trigger</code> to create custom trigger elements beyond standard                buttons. This example shows a card-style trigger with icons and descriptive text.              </Modal.Description>            </Modal.Body>            <Modal.Footer>              <Modal.Close render={<Button variant="secondary" />}>Cancel</Modal.Close>              <Modal.Close render={<Button />}>Save</Modal.Close>            </Modal.Footer>          </Modal.Popup>        </Modal.Viewport>      </Modal.Portal>    </Modal>  );}

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

Backdrop Variants

"use client";// Adapted from HeroUI v3.2.6 modal-backdrop-variants (Apache-2.0).import { Rocket } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  row: { display: "flex", flexWrap: "wrap", gap: 16 },  popup: { maxWidth: 360 },  icon: { backgroundColor: "var(--default)", color: "var(--foreground)" },  rocket: { width: 20, height: 20 },  action: { width: "100%" },});
export function BackdropVariants() {  const variants = ["opaque", "blur", "transparent"] as const;  return (    <div {...stylex.props(styles.row)}>      {variants.map((variant) => (        <Modal key={variant}>          <Modal.Trigger render={<Button variant="secondary" />}>            {variant.charAt(0).toUpperCase() + variant.slice(1)}          </Modal.Trigger>          <Modal.Portal>            <Modal.Backdrop variant={variant} />            <Modal.Viewport>              <Modal.Popup xstyle={styles.popup}>                <Modal.Close aria-label="Close dialog" />                <Modal.Header>                  <Modal.Icon xstyle={styles.icon}>                    <Rocket {...stylex.props(styles.rocket)} />                  </Modal.Icon>                  <Modal.Title>                    Backdrop: {variant.charAt(0).toUpperCase() + variant.slice(1)}                  </Modal.Title>                </Modal.Header>                <Modal.Body>                  <Modal.Description>                    This modal uses the <code>{variant}</code> backdrop variant. Compare the                    different visual effects: opaque provides full opacity, blur adds a backdrop                    filter, and transparent removes the background.                  </Modal.Description>                </Modal.Body>                <Modal.Footer>                  <Modal.Close render={<Button xstyle={styles.action} />}>Continue</Modal.Close>                </Modal.Footer>              </Modal.Popup>            </Modal.Viewport>          </Modal.Portal>        </Modal>      ))}    </div>  );}

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

Custom Backdrop

"use client";// Adapted from HeroUI v3.2.6 modal-custom-backdrop (Apache-2.0).import { Sparkles } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  backdrop: {    backgroundColor: "transparent",    backgroundImage: {      default: "linear-gradient(to top, rgb(0 0 0 / 80%), rgb(0 0 0 / 40%), transparent)",      ':where([data-theme="dark"]) &':        "linear-gradient(to top, rgb(39 39 42 / 80%), rgb(39 39 42 / 40%), transparent)",    },  },  popup: { maxWidth: 360 },  header: { alignItems: "center", textAlign: "center" },  icon: { backgroundColor: "var(--accent-soft)", color: "var(--accent-soft-foreground)" },  sparkles: { width: 20, height: 20 },  footer: { flexDirection: "column-reverse" },  action: { width: "100%" },});
export function CustomBackdrop() {  return (    <Modal>      <Modal.Trigger render={<Button variant="secondary" />}>Custom Backdrop</Modal.Trigger>      <Modal.Portal>        <Modal.Backdrop variant="blur" xstyle={styles.backdrop} />        <Modal.Viewport>          <Modal.Popup xstyle={styles.popup}>            <Modal.Header xstyle={styles.header}>              <Modal.Icon xstyle={styles.icon}>                <Sparkles {...stylex.props(styles.sparkles)} />              </Modal.Icon>              <Modal.Title>Premium Backdrop</Modal.Title>            </Modal.Header>            <Modal.Body>              <Modal.Description>                This backdrop features a sophisticated gradient that transitions from a dark color                at the bottom to complete transparency at the top, combined with a smooth blur                effect. The gradient automatically adapts its intensity for optimal contrast in both                light and dark modes.              </Modal.Description>            </Modal.Body>            <Modal.Footer xstyle={styles.footer}>              <Modal.Close render={<Button xstyle={styles.action} />}>Amazing!</Modal.Close>              <Modal.Close render={<Button variant="secondary" xstyle={styles.action} />}>                Close              </Modal.Close>            </Modal.Footer>            <Modal.Close aria-label="Close dialog" />          </Modal.Popup>        </Modal.Viewport>      </Modal.Portal>    </Modal>  );}

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

Dismiss Behavior

"use client";// Adapted from HeroUI v3.2.6 modal-dismiss-behavior (Apache-2.0).import { CircleInfo } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  column: { display: "flex", maxWidth: 384, flexDirection: "column", gap: 24 },  section: { display: "flex", flexDirection: "column", gap: 8 },  heading: { fontSize: 18, fontWeight: 600 },  caption: { fontSize: 14, lineHeight: "20px", color: "var(--muted)" },  popup: { maxWidth: 360 },  icon: { backgroundColor: "var(--default)", color: "var(--foreground)" },  circle: { width: 20, height: 20 },  action: { width: "100%" },});
export function DismissBehavior() {  return (    <div {...stylex.props(styles.column)}>      {(["outside-press", "escape-key"] as const).map((blockedReason) => (        <div key={blockedReason} {...stylex.props(styles.section)}>          <h3 {...stylex.props(styles.heading)}>            {blockedReason === "outside-press" ? "Backdrop dismissal" : "Keyboard dismissal"}          </h3>          <p {...stylex.props(styles.caption)}>            {blockedReason === "outside-press"              ? "This modal requires an explicit close action or ESC instead of a backdrop click."              : "ESC is disabled. Use an explicit close action or click the backdrop."}          </p>          <Modal            onOpenChange={(open, details) => {              if (!open && details.reason === blockedReason) details.cancel();            }}          >            <Modal.Trigger render={<Button variant="secondary" />}>Open Modal</Modal.Trigger>            <Modal.Portal>              <Modal.Backdrop />              <Modal.Viewport>                <Modal.Popup xstyle={styles.popup}>                  <Modal.Close aria-label="Close dialog" />                  <Modal.Header>                    <Modal.Icon xstyle={styles.icon}>                      <CircleInfo {...stylex.props(styles.circle)} />                    </Modal.Icon>                    <Modal.Title>                      {blockedReason === "outside-press"                        ? "Backdrop dismissal disabled"                        : "Keyboard dismissal disabled"}                    </Modal.Title>                    <Modal.Description xstyle={styles.caption}>                      {blockedReason === "outside-press"                        ? "Clicking the backdrop won't close this modal"                        : "ESC key is disabled"}                    </Modal.Description>                  </Modal.Header>                  <Modal.Body>                    <p>                      {blockedReason === "outside-press"                        ? "Try clicking outside this modal on the overlay - it won't close. You must use the close button or press ESC to dismiss it."                        : "Press ESC - nothing happens. You must use the close button or click the overlay backdrop to dismiss this modal."}                    </p>                  </Modal.Body>                  <Modal.Footer>                    <Modal.Close render={<Button xstyle={styles.action} />}>Close</Modal.Close>                  </Modal.Footer>                </Modal.Popup>              </Modal.Viewport>            </Modal.Portal>          </Modal>        </div>      ))}    </div>  );}

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

Close Methods

"use client";// Adapted from HeroUI v3.2.6 modal-close-methods (Apache-2.0).import { CircleCheck, CircleInfo } from "@gravity-ui/icons";import { useRef } from "react";import { Dialog } from "@base-ui/react/dialog";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  column: { display: "flex", maxWidth: 672, flexDirection: "column", gap: 32 },  section: { display: "flex", flexDirection: "column", gap: 8 },  heading: { fontSize: 18, fontWeight: 600 },  caption: { fontSize: 14, color: "var(--muted)" },  popup: { maxWidth: 360 },  accent: { backgroundColor: "var(--accent-soft)", color: "var(--accent-soft-foreground)" },  success: { backgroundColor: "var(--success-soft)", color: "var(--success-soft-foreground)" },  circle: { width: 20, height: 20 },});
export function CloseMethods() {  const actionsRef = useRef<Dialog.Root.Actions | null>(null);  return (    <div {...stylex.props(styles.column)}>      <div {...stylex.props(styles.section)}>        <h3 {...stylex.props(styles.heading)}>Using Modal.Close</h3>        <p {...stylex.props(styles.caption)}>          Compose a Button with <code>Modal.Close</code> to close the modal automatically.        </p>        <Modal>          <Modal.Trigger render={<Button variant="secondary" />}>Open Modal</Modal.Trigger>          <Modal.Portal>            <Modal.Backdrop />            <Modal.Viewport>              <Modal.Popup xstyle={styles.popup}>                <Modal.Header>                  <Modal.Icon xstyle={styles.accent}>                    <CircleInfo {...stylex.props(styles.circle)} />                  </Modal.Icon>                  <Modal.Title>Using Modal.Close</Modal.Title>                </Modal.Header>                <Modal.Body>                  <Modal.Description>                    Click either button below - both use <code>Modal.Close</code> and will close the                    modal automatically.                  </Modal.Description>                </Modal.Body>                <Modal.Footer>                  <Modal.Close render={<Button variant="secondary" />}>Cancel</Modal.Close>                  <Modal.Close render={<Button />}>Confirm</Modal.Close>                </Modal.Footer>              </Modal.Popup>            </Modal.Viewport>          </Modal.Portal>        </Modal>      </div>      <div {...stylex.props(styles.section)}>        <h3 {...stylex.props(styles.heading)}>Using Root actions</h3>        <p {...stylex.props(styles.caption)}>          Access the native <code>close</code> method through the Root&apos;s{" "}          <code>actionsRef</code>. This gives you full control over when and how to close the modal,          allowing you to add custom logic before closing.        </p>        <Modal actionsRef={actionsRef}>          <Modal.Trigger render={<Button variant="secondary" />}>Open Modal</Modal.Trigger>          <Modal.Portal>            <Modal.Backdrop />            <Modal.Viewport>              <Modal.Popup xstyle={styles.popup}>                <Modal.Header>                  <Modal.Icon xstyle={styles.success}>                    <CircleCheck {...stylex.props(styles.circle)} />                  </Modal.Icon>                  <Modal.Title>Using Root actions</Modal.Title>                </Modal.Header>                <Modal.Body>                  <Modal.Description>                    The buttons below use the <code>close</code> method from Root actions. You can                    add validation or other logic before calling{" "}                    <code>actionsRef.current.close()</code>.                  </Modal.Description>                </Modal.Body>                <Modal.Footer>                  <Button variant="secondary" onClick={() => actionsRef.current?.close()}>                    Cancel                  </Button>                  <Button onClick={() => actionsRef.current?.close()}>Confirm</Button>                </Modal.Footer>              </Modal.Popup>            </Modal.Viewport>          </Modal.Portal>        </Modal>      </div>    </div>  );}

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

Custom Animations

"use client";// Adapted from HeroUI v3.2.6 modal-custom-animations (Apache-2.0).import { ArrowUpFromLine, Sparkles } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const scaleIn = stylex.keyframes({  from: { opacity: 0, transform: "scale(.95)" },  to: { opacity: 1, transform: "scale(1)" },});const scaleOut = stylex.keyframes({  from: { opacity: 1, transform: "scale(1)" },  to: { opacity: 0, transform: "scale(.95)" },});const slideIn = stylex.keyframes({  from: { opacity: 0, transform: "translateY(16px)" },  to: { opacity: 1, transform: "translateY(0)" },});const slideOut = stylex.keyframes({  from: { opacity: 1, transform: "translateY(0)" },  to: { opacity: 0, transform: "translateY(8px)" },});const styles = stylex.create({  row: { display: "flex", flexWrap: "wrap", gap: 16 },  popup: { maxWidth: 360 },  icon: { backgroundColor: "var(--default)", color: "var(--foreground)" },  glyph: { width: 20, height: 20 },  description: { marginTop: 4 },  scaleBackdrop: {    transitionDuration: {      default: "400ms",      ":is([data-ending-style])": "200ms",      "@media (prefers-reduced-motion: reduce)": "0ms",    },    transitionTimingFunction: {      default: "cubic-bezier(.16,1,.3,1)",      ":is([data-ending-style])": "cubic-bezier(.7,0,.84,0)",    },  },  slideBackdrop: {    transitionDuration: {      default: "500ms",      ":is([data-ending-style])": "200ms",      "@media (prefers-reduced-motion: reduce)": "0ms",    },    transitionTimingFunction: {      default: "cubic-bezier(.25,1,.5,1)",      ":is([data-ending-style])": "cubic-bezier(.5,0,.75,0)",    },  },  scale: {    animationName: {      default: scaleIn,      ":is([data-ending-style])": scaleOut,      "@media (prefers-reduced-motion: reduce)": "none",    },    animationDuration: { default: "400ms", ":is([data-ending-style])": "200ms" },    animationTimingFunction: {      default: "cubic-bezier(.16,1,.3,1)",      ":is([data-ending-style])": "cubic-bezier(.7,0,.84,0)",    },    transitionProperty: "none",  },  slide: {    animationName: {      default: slideIn,      ":is([data-ending-style])": slideOut,      "@media (prefers-reduced-motion: reduce)": "none",    },    animationDuration: { default: "500ms", ":is([data-ending-style])": "200ms" },    animationTimingFunction: {      default: "cubic-bezier(.25,1,.5,1)",      ":is([data-ending-style])": "cubic-bezier(.5,0,.75,0)",    },    transitionProperty: "none",  },});
export function CustomAnimations() {  const animations = [    {      name: "Kinematic Scale",      Icon: Sparkles,      popup: styles.scale,      backdrop: styles.scaleBackdrop,      description:        "Physics-based elastic scaling. Simulates a high-damping spring system with fast transient response and prolonged settling time. Ideal for Modals and Popovers.",    },    {      name: "Fluid Slide",      Icon: ArrowUpFromLine,      popup: styles.slide,      backdrop: styles.slideBackdrop,      description:        "Simulates movement through a medium with fluid resistance. Eliminates mechanical linearity for a natural, grounded feel. Perfect for Bottom Sheets or Toasts.",    },  ];  return (    <div {...stylex.props(styles.row)}>      {animations.map(({ name, Icon, popup, backdrop, description }) => (        <Modal key={name}>          <Modal.Trigger render={<Button variant="secondary" />}>{name}</Modal.Trigger>          <Modal.Portal>            <Modal.Backdrop xstyle={backdrop} />            <Modal.Viewport>              <Modal.Popup xstyle={[styles.popup, popup]}>                <Modal.Close aria-label="Close dialog" />                <Modal.Header>                  <Modal.Icon xstyle={styles.icon}>                    <Icon {...stylex.props(styles.glyph)} />                  </Modal.Icon>                  <Modal.Title>{name} Animation</Modal.Title>                </Modal.Header>                <Modal.Body>                  <Modal.Description xstyle={styles.description}>{description}</Modal.Description>                </Modal.Body>                <Modal.Footer>                  <Modal.Close render={<Button variant="tertiary" />}>Close</Modal.Close>                  <Modal.Close render={<Button />}>Try Again</Modal.Close>                </Modal.Footer>              </Modal.Popup>            </Modal.Viewport>          </Modal.Portal>        </Modal>      ))}    </div>  );}

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

Custom Portal

"use client";// Adapted from HeroUI v3.2.6 modal-custom-portal (Apache-2.0).import { useCallback, useState } from "react";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  column: { display: "flex", flexDirection: "column", gap: 16 },  text: { fontSize: 14 },  caption: { fontSize: 14, color: "var(--muted)" },  code: { borderRadius: 4, paddingInline: 4, paddingBlock: 2, fontSize: 12 },  container: {    position: "relative",    display: "flex",    height: 380,    alignItems: "center",    justifyContent: "center",    overflow: "hidden",    borderRadius: "var(--radius)",    backgroundColor: "color-mix(in oklab, var(--muted) 20%, transparent)",    transform: "translateZ(0)",  },  backdrop: { height: "100%" },  viewport: { height: "100%", maxHeight: "100%" },  popup: { height: "100%", maxHeight: "100%", maxWidth: 448 },});
export function CustomPortal() {  const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null);  const setPortalRef = useCallback((node: HTMLDivElement | null) => setPortalContainer(node), []);  return (    <div {...stylex.props(styles.column)}>      <div>        <p {...stylex.props(styles.text)}>          Render modals inside a custom container instead of <code>document.body</code>        </p>        <p {...stylex.props(styles.caption)}>          Apply <code {...stylex.props(styles.code)}>transform: translateZ(0)</code> to the          container to create a new stacking context.        </p>      </div>      <div ref={setPortalRef} {...stylex.props(styles.container)}>        {!!portalContainer && (          <Modal>            <Modal.Trigger render={<Button />}>Open Modal</Modal.Trigger>            <Modal.Portal container={portalContainer}>              <Modal.Backdrop xstyle={styles.backdrop} />              <Modal.Viewport xstyle={styles.viewport}>                <Modal.Popup xstyle={styles.popup}>                  <Modal.Close aria-label="Close dialog" />                  <Modal.Header>                    <Modal.Title>Custom Portal</Modal.Title>                  </Modal.Header>                  <Modal.Body>                    {Array.from({ length: 3 }, (_, index) => (                      <p key={index} {...stylex.props(styles.caption)}>                        Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod                        tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,                        quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo                        consequat.                      </p>                    ))}                  </Modal.Body>                  <Modal.Footer>                    <Modal.Close render={<Button variant="secondary" />}>Close</Modal.Close>                  </Modal.Footer>                </Modal.Popup>              </Modal.Viewport>            </Modal.Portal>          </Modal>        )}      </div>    </div>  );}

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

Customization

Tailwind CSS

"use client";// Adapted from HeroUI v3.2.6 modal-custom-styles (Apache-2.0).import { CircleCheck } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { Button, Modal } from "@lenso/ui";
const styles = stylex.create({  backdrop: {    backgroundColor: {      default: "color-mix(in oklab, var(--overlay) 50%, transparent)",      ':where([data-theme="dark"]) &': "color-mix(in oklab, var(--overlay) 65%, transparent)",    },  },  popup: {    position: "relative",    overflow: "hidden",    maxWidth: 340,    borderWidth: 1,    borderStyle: "solid",    borderColor: {      default: "color-mix(in oklab, var(--border) 80%, transparent)",      ':where([data-theme="dark"]) &': "color-mix(in oklab, var(--border) 90%, transparent)",    },    backgroundColor: {      default: "color-mix(in oklab, var(--surface) 90%, transparent)",      ':where([data-theme="dark"]) &': "color-mix(in oklab, var(--surface) 85%, transparent)",    },    boxShadow: {      default: "0 25px 50px -12px rgb(0 0 0 / 25%), 0 0 0 1px rgb(0 0 0 / 5%)",      ':where([data-theme="dark"]) &':        "0 25px 50px -12px rgb(0 0 0 / 25%), 0 0 0 1px rgb(255 255 255 / 10%)",    },    backdropFilter: "blur(24px)",  },  glow: {    pointerEvents: "none",    position: "absolute",    insetInline: 0,    top: 0,    height: 80,    backgroundImage: {      default: "linear-gradient(to bottom, rgb(115 115 115 / 8%), transparent)",      ':where([data-theme="dark"]) &':        "linear-gradient(to bottom, rgb(163 163 163 / 10%), transparent)",    },  },  line: {    pointerEvents: "none",    position: "absolute",    insetInline: 40,    top: 0,    height: 1,    backgroundImage: {      default: "linear-gradient(to right, transparent, rgb(163 163 163 / 40%), transparent)",      ':where([data-theme="dark"]) &':        "linear-gradient(to right, transparent, rgb(115 115 115 / 35%), transparent)",    },  },  relative: { position: "relative" },  icon: {    backgroundColor: {      default: "rgb(245 245 245)",      ':where([data-theme="dark"]) &': "rgb(38 38 38)",    },    color: { default: "rgb(64 64 64)", ':where([data-theme="dark"]) &': "rgb(229 229 229)" },  },  circle: { width: 20, height: 20 },  caption: { fontSize: 14, color: "var(--muted)" },  action: { width: "100%" },});
export function CustomStyles() {  return (    <Modal>      <Modal.Trigger render={<Button variant="secondary" />}>Open</Modal.Trigger>      <Modal.Portal>        <Modal.Backdrop variant="blur" xstyle={styles.backdrop} />        <Modal.Viewport>          <Modal.Popup xstyle={styles.popup}>            <div aria-hidden="true" {...stylex.props(styles.glow)} />            <div aria-hidden="true" {...stylex.props(styles.line)} />            <Modal.Close aria-label="Close dialog" />            <Modal.Header xstyle={styles.relative}>              <Modal.Icon xstyle={styles.icon}>                <CircleCheck {...stylex.props(styles.circle)} />              </Modal.Icon>              <Modal.Title>Changes saved</Modal.Title>            </Modal.Header>            <Modal.Body xstyle={styles.relative}>              <Modal.Description xstyle={styles.caption}>                Your draft is synced across devices.              </Modal.Description>            </Modal.Body>            <Modal.Footer>              <Modal.Close render={<Button xstyle={styles.action} />}>Done</Modal.Close>            </Modal.Footer>          </Modal.Popup>        </Modal.Viewport>      </Modal.Portal>    </Modal>  );}

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

Global CSS

To customize the Modal component classes, you can use the @layer components directive.

Learn more.

@layer components {  .modal__backdrop {    @apply bg-gradient-to-br from-black/50 to-black/70;  }
  .modal__dialog {    @apply rounded-2xl border border-white/10 shadow-2xl;  }
  .modal__header {    @apply text-center;  }
  .modal__close-trigger {    @apply rounded-full bg-white/10 hover:bg-white/20;  }}

Styling Reference

HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.

CSS Classes

The Modal component uses these CSS classes (View source styles):

Base Classes [!toc]

  • .modal__trigger - Trigger element that opens the modal
  • .modal__backdrop - Overlay backdrop behind the modal
  • .modal__container - Positioning wrapper with placement support
  • .modal__dialog - Modal content container
  • .modal__header - Header section for titles and icons
  • .modal__body - Main content area
  • .modal__footer - Footer section for actions
  • .modal__close-trigger - Close button element

Backdrop Variants [!toc]

  • .modal__backdrop--opaque - Opaque colored backdrop (default)
  • .modal__backdrop--blur - Blurred backdrop with glass effect
  • .modal__backdrop--transparent - Transparent backdrop (no overlay)

Scroll Variants [!toc]

  • .modal__container--scroll-outside - Enables scrolling the entire modal
  • .modal__dialog--scroll-inside - Constrains modal height for body scrolling
  • .modal__body--scroll-inside - Makes only the body scrollable
  • .modal__body--scroll-outside - Allows full-page scrolling

Interactive States

The component supports these interactive states:

  • Focus: :focus-visible or [data-focus-visible="true"] - Applied to trigger, dialog, and close button
  • Hover: :hover or [data-hovered="true"] - Applied to close button on hover
  • Active: :active or [data-pressed="true"] - Applied to close button when pressed
  • Entering: [data-entering] - Applied during modal opening animation
  • Exiting: [data-exiting] - Applied during modal closing animation
  • Placement: [data-placement="*"] - Applied based on modal position (auto, top, center, bottom)

API Reference

PropTypeDefaultDescription
childrenReactNode-Trigger and container elements

Modal.Trigger

PropTypeDefaultDescription
childrenReactNode-Custom trigger content
classNamestring-CSS classes

Modal.Backdrop

PropTypeDefaultDescription
variant"opaque" | "blur" | "transparent""opaque"Backdrop overlay style
isDismissablebooleantrueClose on backdrop click
isKeyboardDismissDisabledbooleanfalseDisable ESC key to close
isOpenboolean-Controlled open state
onOpenChange(isOpen: boolean) => void-Open state change handler
classNamestring | (values) => string-Backdrop CSS classes
UNSTABLE_portalContainerHTMLElement-Custom portal container

Modal.Container

PropTypeDefaultDescription
placement"auto" | "center" | "top" | "bottom""auto"Modal position on screen
scroll"inside" | "outside""inside"Scroll behavior
size"xs" | "sm" | "md" | "lg" | "cover" | "full""md"Modal size variant
classNamestring | (values) => string-Container CSS classes

Modal.Dialog

PropTypeDefaultDescription
childrenReactNode | ({close}) => ReactNode-Content or render function
classNamestring | (values) => string-CSS classes
rolestring"dialog"ARIA role
aria-labelstring-Accessibility label
aria-labelledbystring-ID of label element
aria-describedbystring-ID of description element

Modal.Header

PropTypeDefaultDescription
childrenReactNode-Header content
classNamestring-CSS classes

Modal.Body

PropTypeDefaultDescription
childrenReactNode-Body content
classNamestring-CSS classes

Modal.Footer

PropTypeDefaultDescription
childrenReactNode-Footer content
classNamestring-CSS classes

Modal.CloseTrigger

PropTypeDefaultDescription
childrenReactNode-Custom close button
classNamestring | (values) => string-CSS classes

useOverlayState Hook

import {useOverlayState} from "@lenso/ui";
const state = useOverlayState({  defaultOpen: false,  onOpenChange: (isOpen) => console.log(isOpen),});
state.isOpen; // Current statestate.open(); // Open modalstate.close(); // Close modalstate.toggle(); // Toggle statestate.setOpen(); // Set state directly

Accessibility

Implements WAI-ARIA Dialog pattern:

  • Focus trap: Focus locked within modal
  • Keyboard: ESC closes (when enabled), Tab cycles elements
  • Screen readers: Proper ARIA attributes
  • Scroll lock: Body scroll disabled when open