React Clean UI
Interaction

Modal

Display dismissible content in an overlay above the rest of the interface.

Modal renders a full-screen backdrop (a Roblox ImageButton) behind a fading CanvasGroup panel that contains a Card. It is rendered through the library's overlay system — the same portal mechanism used by Select's dropdown and Tooltip — so it always appears above the rest of the interface regardless of where it is declared in the component tree.

Modal requires the app to be wrapped in CleanUiProvider. If your app already uses this library's other overlay-based components (Select, Tooltip), no extra setup is needed.

Import

import { Modal, Card, useModalClose } from "@rbxts/react-clean-ui";

Basic usage

Compose the modal's content from Card.Header, Card.Body, and Card.Footer — the same sections documented on the Card page. Modal does not expose its own Modal.Header/Modal.Body/Modal.Footer.

Loom Previewv0.13.2

Sizing

Use the inherited Size, width, and height props to size the panel. Size takes precedence over width and height. Without Size, width defaults to theme.components.modal.width and height sizes automatically to the panel's content unless height is provided.

Percentage values such as width="60%" and height="50%", along with scale components in a UDim2 supplied through Size, resolve against the full overlay. Automatic axes continue to size to their content according to AutomaticSize.

Add minWidth, maxWidth, minHeight, or maxHeight to clamp the panel in pixels (see min and max size). The panel stays centred, and draggable when draggable is set, at its clamped size. Minimums apply to every axis. Maximums cap an axis sized by width, height, a percentage, or Size, such as the default theme width. An automatically sized axis, such as the default height, still grows to fit its content.

<Modal width="90%" maxWidth={720}>
    ...
</Modal>

Repositioning the panel

Set draggable to allow the modal to be repositioned by dragging a direct Card.Header child. Releasing the header keeps the panel at its dropped position instead of snapping it back to the center, including through ordinary rerenders while that modal remains mounted and open. Interactions that begin elsewhere in the panel do not start a drag, so body and footer controls remain usable normally.

<Modal open={open} onOpenChange={setOpen} draggable>
    <Card.Header>
        <Text variant="heading" text="Drag this header" />
    </Card.Header>
    <Card.Body>
        <Text text="The panel remains where you drop it." />
    </Card.Body>
</Modal>

Controlled vs. uncontrolled

Supplying open makes the modal controlled — its visibility follows the prop, and onOpenChange is called whenever the modal wants to change (a backdrop click, Escape, gamepad B, or a close button calling useModalClose()), but the modal only actually opens or closes once you update open in response.

const [open, setOpen] = React.useState(false);

<Modal open={open} onOpenChange={setOpen}>
    {/* ... */}
</Modal>

Omit open for an uncontrolled modal that manages its own visibility internally, optionally starting open via defaultOpen.

<Modal defaultOpen onOpenChange={(open) => print(`Modal is now ${open}`)}>
    {/* ... */}
</Modal>

Closing from within content

Use useModalClose() inside a Modal's children to close the nearest enclosing modal, without threading a close handler down manually. This is useful for a header close button.

import { Card, Icon, Modal, Text, useModalClose } from "@rbxts/react-clean-ui";

function ModalCloseButton() {
    const close = useModalClose();

    return <Icon icon="times" Event={{ Activated: () => close() }} />;
}

<Modal open={open} onOpenChange={setOpen}>
    <Card.Header>
        <Text variant="heading" text="Settings" />
        <ModalCloseButton />
    </Card.Header>
    <Card.Body>
        <Text text="Modal content goes here." />
    </Card.Body>
</Modal>

Dismissal

By default the modal closes when the dimmed backdrop outside the panel is clicked, or when Escape (or the gamepad B button) is pressed. Clicking anywhere inside the panel, including non-interactive text or background areas, does not dismiss the modal. Set closeOnBackdropClick or closeOnEscape to false to disable either behavior individually — useful for a modal that must be dismissed through an explicit action.

<Modal
    open={open}
    onOpenChange={setOpen}
    closeOnBackdropClick={false}
    closeOnEscape={false}
>
    <Card.Header>
        <Text variant="heading" text="Processing..." />
    </Card.Header>
    <Card.Body>
        <Text text="Please wait, this dialog cannot be dismissed." />
    </Card.Body>
</Modal>

Focus and selection

Behaviour change in 2.0.0

By default, a modal now takes the selection (GuiService.SelectedObject) only for gamepad players. Mouse, keyboard and touch players no longer get Roblox's selection outline when a modal opens. Gamepad players behave as before. Pass selectionMode="always" to keep the old behaviour.

A player counts as using a gamepad while their last input (UserInputService:GetLastInputType()) is one of Gamepad1 to Gamepad8. "Inside the modal" covers the backdrop, the panel and everything in it.

  • On open: the modal remembers what was selected just before it opened. If the player is using a gamepad, selection moves to initialFocus if you pass one, otherwise to the panel itself. For anyone else, the selection is left alone.
  • While open: the modal follows input switches. When the player switches to a gamepad and nothing inside the modal is selected, the modal selects initialFocus (or the panel). Only the topmost open modal does this, so a modal lower in the stack never takes the selection from the one above it. When the player switches away from the gamepad, a selection inside the modal is cleared. A selection outside the modal is left alone.
  • On close, after the fade: if the player is using a gamepad, the selection goes back to what was selected before the modal opened. Each modal remembers its own previous selection, so nested modals restore independently. Otherwise, a selection still inside the modal is cleared and nothing is restored, so a mouse player doesn't get an outline when the modal closes.

Pass a React.RefObject<GuiObject> as initialFocus to choose which element gets selected instead of the panel:

const confirmRef = React.useRef<ImageButton>();

<Modal open={open} onOpenChange={setOpen} initialFocus={confirmRef}>
    <Card.Body>
        <Text text="Delete this item?" />
    </Card.Body>
    <Card.Footer>
        <HStack>
            <Button text="Cancel" Event={{ Activated: () => setOpen(false) }} />
            <Button ref={confirmRef} text="Delete" intent="danger" Event={{ Activated: () => setOpen(false) }} />
        </HStack>
    </Card.Footer>
</Modal>

Selecting for every input

Set selectionMode="always" to go back to the behaviour from before 2.0.0. The modal then takes the selection every time it opens, whatever input the player is using, never clears it on an input switch, and always restores the previous selection on close.

<Modal open={open} onOpenChange={setOpen} initialFocus={confirmRef} selectionMode="always">
    {/* ... */}
</Modal>

A GamepadSelectionGuard mounted in the place still clears the selection for players who aren't using a gamepad, including a selection taken by a selectionMode="always" modal. The guard wins.

Stacking modals

Nest a <Modal> inside another modal's content to open a second modal on top of the first. Each layer stacks visually above the last, and Escape or the gamepad B button only ever closes the topmost modal.

function NestedModalExample() {
    const [outerOpen, setOuterOpen] = React.useState(false);
    const [innerOpen, setInnerOpen] = React.useState(false);

    return (
        <>
            <Button text="Open" Event={{ Activated: () => setOuterOpen(true) }} />

            <Modal open={outerOpen} onOpenChange={setOuterOpen}>
                <Card.Header>
                    <Text variant="heading" text="Step 1" />
                </Card.Header>
                <Card.Body>
                    <Button text="Continue" Event={{ Activated: () => setInnerOpen(true) }} />
                </Card.Body>

                <Modal open={innerOpen} onOpenChange={setInnerOpen}>
                    <Card.Header>
                        <Text variant="heading" text="Step 2" />
                    </Card.Header>
                    <Card.Body>
                        <Text text="Pressing Escape here only closes this modal." />
                    </Card.Body>
                </Modal>
            </Modal>
        </>
    );
}

Props

PropTypeDefaultDescription
openbooleanundefinedMakes the modal controlled. When set, visibility follows this value instead of internal state.
defaultOpenbooleanfalseInitial visibility for an uncontrolled modal.
onOpenChange(open: boolean) => voidundefinedCalled whenever the modal wants to open or close (backdrop click, Escape, gamepad B, or useModalClose()).
closeOnBackdropClickbooleantrueWhether clicking the dimmed area outside the panel closes the modal.
closeOnEscapebooleantrueWhether Escape or the gamepad B button closes the modal. Only ever closes the topmost modal in a stack.
draggablebooleanfalseEnables dragging by direct Card.Header children and retains the dropped position while mounted and open.
initialFocusReact.RefObject<GuiObject>undefinedElement to select when the modal takes the selection. Defaults to the modal panel itself.
selectionMode"gamepad" | "always""gamepad"When the modal takes the selection. "gamepad" selects only for gamepad players and follows input switches; "always" selects on every open whatever the input (the behaviour before 2.0.0).
childrenReact.ReactNodeundefinedModal content, composed from Card.Header, Card.Body, and Card.Footer.

Shared props

The modal also supports props inherited from the following interfaces, forwarded to its internal Card.

InterfacePurpose
BoxPropsConfigures the panel's sizing, spacing, background, border, shadow, and position. Size takes precedence over width/height; percentage and scale sizes resolve against the full overlay. minWidth/maxWidth/minHeight/maxHeight clamp the panel in pixels.
IntentElementPropsConfigures the intent applied to the panel's Card.

Behaviour

  • Requires the app to be wrapped in CleanUiProvider, which supplies both the shared overlay layer and the modal stack.
  • Renders through a portal into the app's overlay layer, so it appears above normal page content regardless of where it is declared in the tree.
  • Backdrop dismissal only responds to clicks outside the panel; clicks within the panel do not reach the backdrop, including clicks on non-interactive content.
  • Panel percentage and scale sizes resolve against the full overlay, while axes selected by AutomaticSize size to the panel's content.
  • When draggable is enabled, dragging a direct Card.Header child repositions the panel and retains the dropped position through ordinary rerenders while the modal remains mounted and open; dragging elsewhere in the panel has no effect. Without a direct Card.Header, the modal has no drag handle.
  • Fades the backdrop and panel in and out over theme.components.modal.fadeDuration seconds; the portal content stays mounted for the duration of the closing fade before actually unmounting. A duration of 0 skips the fade and unmounts immediately.
  • Supports multiple simultaneously open modals by nesting a <Modal> inside another's children — each additional layer renders with a higher ZIndex than the last, so it visually stacks above it.
  • Escape and the gamepad B button only ever close the topmost visible modal in the stack, never a modal beneath it.
  • With the default selectionMode="gamepad", the modal only takes the selection for gamepad players: on open it selects initialFocus (if provided) or otherwise the panel itself, and mouse, keyboard and touch players get no selection outline. While open, switching to a gamepad selects into the topmost modal if nothing inside it is selected, and switching away clears a selection inside the modal.
  • On close, a gamepad player's selection is restored to whatever was selected immediately before that modal opened — scoped per instance, so nested modals restore focus independently. For other inputs, a selection left inside the modal is cleared and nothing is restored.
  • With selectionMode="always", the modal selects on every open and restores on every close, whatever the input, and ignores input switches. A mounted GamepadSelectionGuard still clears that selection for non-gamepad players.
  • Setting GuiService.SelectedObject fails when the modal isn't under a PlayerGui (for example in a ui-labs preview); the modal ignores the failure and simply doesn't select anything.
  • When open is not provided, the modal manages its own visibility starting from defaultOpen.

Theme values

The modal uses values from theme.components.modal for:

  • The fade-in/fade-out duration for the backdrop and panel
  • The base ZIndex of the first modal layer, and the increment added per additional stacked layer
  • The panel's default width (used when width isn't set on the Modal)
  • The backdrop's background color and transparency

These values can be changed by providing a custom Clean UI theme.

GitHub Repository

On this page