Modal 模态框
用于聚焦用户交互与重要内容的对话框遮罩层。
用法
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.
组件结构
导入 Modal 组件后,可通过点语法访问所有子部分。
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>);示例
尺寸
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
位置
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
滚动行为
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
受控状态
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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'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'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.
搭配表单
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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'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.
自定义触发器
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
遮罩变体
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
自定义遮罩
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
关闭行为
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
关闭方式
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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'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.
自定义动画
此预览复用英文版适配,不代表中文源示例已完成本地实现。
"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.
自定义 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.
自定义样式
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.
全局 CSS
若要自定义组件类,可使用 @layer components 指令。了解更多。
@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; }}样式参考
HeroUI 遵循 BEM 方法论,确保组件变体与状态可复用且易于自定义。
CSS 类
Modal 使用以下 CSS 类(查看源码样式):
基础类 [!toc]
.modal__trigger— 打开 Modal 的触发元素.modal__backdrop— Modal 背后的遮罩层.modal__container— 支持 placement 的定位包裹层.modal__dialog— Modal 内容容器.modal__header— 标题与图标区域.modal__body— 主内容区域.modal__footer— 操作区域.modal__close-trigger— 关闭按钮元素
遮罩变体 [!toc]
.modal__backdrop--opaque— 不透明有色遮罩(默认).modal__backdrop--blur— 带玻璃效果的模糊遮罩.modal__backdrop--transparent— 透明遮罩(无叠加层)
滚动变体 [!toc]
.modal__container--scroll-outside— 允许整个 Modal 滚动.modal__dialog--scroll-inside— 限制 Modal 高度,由 body 区域滚动.modal__body--scroll-inside— 仅 body 区域可滚动.modal__body--scroll-outside— 允许整页滚动
交互状态
组件支持以下交互状态:
- 焦点:
:focus-visible或[data-focus-visible="true"]— 应用于触发器、对话框与关闭按钮 - 悬停:
:hover或[data-hovered="true"]— 应用于关闭按钮悬停 - 按下:
:active或[data-pressed="true"]— 应用于关闭按钮按下 - 进入:
[data-entering]— Modal 打开动画期间 - 离开:
[data-exiting]— Modal 关闭动画期间 - 位置:
[data-placement="*"]— 基于 Modal 位置(auto、top、center、bottom)
API 参考
Modal
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
children | ReactNode | - | 触发器与容器元素 |
Modal.Trigger
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
children | ReactNode | - | 自定义触发内容 |
className | string | - | CSS 类 |
Modal.Backdrop
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
variant | "opaque" | "blur" | "transparent" | "opaque" | 遮罩叠加样式 |
isDismissable | boolean | true | 点击遮罩是否关闭 |
isKeyboardDismissDisabled | boolean | false | 是否禁用 ESC 关闭 |
isOpen | boolean | - | 受控打开状态 |
onOpenChange | (isOpen: boolean) => void | - | 打开状态变化处理函数 |
className | string | (values) => string | - | 遮罩 CSS 类 |
UNSTABLE_portalContainer | HTMLElement | - | 自定义 portal 容器 |
Modal.Container
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
placement | "auto" | "center" | "top" | "bottom" | "auto" | Modal 在屏幕上的位置 |
scroll | "inside" | "outside" | "inside" | 滚动行为 |
size | "xs" | "sm" | "md" | "lg" | "cover" | "full" | "md" | Modal 尺寸变体 |
className | string | (values) => string | - | 容器 CSS 类 |
Modal.Dialog
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
children | ReactNode | ({close}) => ReactNode | - | 内容或渲染函数 |
className | string | (values) => string | - | CSS 类 |
role | string | "dialog" | ARIA role |
aria-label | string | - | 无障碍标签 |
aria-labelledby | string | - | 标签元素的 id |
aria-describedby | string | - | 描述元素的 id |
Modal.Header
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
children | ReactNode | - | 头部内容 |
className | string | - | CSS 类 |
Modal.Body
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
children | ReactNode | - | 正文内容 |
className | string | - | CSS 类 |
Modal.Footer
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
children | ReactNode | - | 底部内容 |
className | string | - | CSS 类 |
Modal.CloseTrigger
| Prop | 类型 | 默认值 | 描述 |
|---|---|---|---|
children | ReactNode | - | 自定义关闭按钮 |
className | string | (values) => string | - | CSS 类 |
useOverlayState Hook
import {useOverlayState} from "@lenso/ui";
const state = useOverlayState({ defaultOpen: false, onOpenChange: (isOpen) => console.log(isOpen),});
state.isOpen; // 当前状态state.open(); // 打开 modalstate.close(); // 关闭 modalstate.toggle(); // 切换状态state.setOpen(); // 直接设置状态无障碍
- 焦点陷阱:焦点锁定在 Modal 内
- 键盘:
ESC关闭(启用时)、Tab在元素间循环 - 屏幕阅读器:正确的 ARIA 属性
- 滚动锁定:打开时禁用 body 滚动