leptos-modal

Leptos crates.io Downloads crates.io Size License crates.io

๐ŸŒ€ Modal composable for Leptos โ€” minimal, type-safe, zero CSS to fight.

leptos-modal is the modal layer every app ends up writing by hand: you wrap the root once, define a modal as a plain component, and open it from anywhere โ€” no mounting it deep in the tree, no prop-drilling a setter down five levels, no z-index wars. The data you pass in is checked by the compiler, not by your users at runtime.

๐Ÿ–ค Features

  • Type-safe by construction โ€” pass the wrong data to a modal and cargo stops you, not the person clicking the button. Generics carry the input and context types end to end.

  • Context and input, kept separate โ€” the constant stuff (like a user-delete callback) goes in once on registration; the dynamic stuff (which user) goes in on open. Two channels, no Option soup.

  • Close from wherever you are โ€” call the close() handed to your modal, or modal.close() from the controller. No setter threaded down the component tree.

  • ARIA-compliant โ€” role="dialog", aria-modal, the lot, out of the box.

  • Esc closes it โ€” keyboard handling you didn't have to wire up.

  • A #[modal] proc macro โ€” write a normal component, get a registerable modal.

  • Zero external CSS dependencies โ€” nothing to import, nothing to override.

  • Portal-style rendering โ€” one place modals render, always exactly one on screen (why would you ever want to show two at once? ๐Ÿคจ), with a z-index that always wins.


Table of Contents

โธป

๐Ÿ“ฆ Installation

Add it to your Cargo.toml:

TOML
[dependencies]
leptos-modal = "0.3"

Or from the terminal:

BASH
cargo add leptos-modal

โธป

๐Ÿงช Usage

Set up the modal collector

Wrap your app with ModalCollector to enable modal rendering. Think of it like a provider in React โ€” it lets any of its descendants instantiate and use modals without each one having to mount them.

RUST
use leptos::prelude::*;
use leptos_modal::prelude::*;

#[component]
fn App() -> impl IntoView {
    view! {
        <ModalCollector>
            <MainContent />
        </ModalCollector>
    }
}

Create a modal component

Say your app has a user list, and you want to delete a user from it. A confirmation dialog would come in handy.

A leptos-modal modal is a component with this signature:

RUST
#[modal]
pub fn ConfirmationModal(input: Input, ctx: Context, close: fn()) -> impl IntoView;

Where:

  • Input is dynamic data you don't know until the modal opens (here, the user to delete). Must satisfy Clone + Send + Sync + 'static.

  • Context is something constant, passed in once on registration and not changeable after (here, the function that does the deleting). Must satisfy Clone + Copy + Send + Sync + 'static.

  • close is the handle that dismisses the modal โ€” call it from inside.

Here's the confirmation dialog:

RUST
use leptos_modal::prelude::*;

#[modal]
pub fn ConfirmationModal(user: User, ctx: Callback<String>, close: fn()) -> impl IntoView {
    view! {
        <div class="confirmation-modal">
            <h2>"Confirm Action"</h2>
            <p>{move || format!("Are you sure you want to delete {}?", user.name)}</p>

            <div class="confirmation-modal__actions">
                <button on:click=move |_| close()>"Cancel"</button>
                <button on:click=move |_| {
                    ctx.run(user.id.clone());
                    close();
                }>"Confirm"</button>
            </div>
        </div>
    }
}

Open and close the modal

Register it with the use_modal! macro, then open it with the input:

RUST
#[derive(Clone)]
struct User {
    id: String,
    name: String,
}

#[component]
fn UsersView(users: Vec<User>) -> impl IntoView {
    let delete_user = Callback::new(move |id: String| {
        // Deletion logic
    });

    // Registers the modal (the context is the delete callback)
    let modal = use_modal!(ConfirmationModal, delete_user);

    let on_delete = move |user: User| {
        modal.open(user);
    };

    view! {
        // โ— Notice the `ConfirmationModal` is not mounted directly
        // anywhere โ€” it is the `ModalCollector`'s job to render modals
        <ul>
            {
                move || users.iter().map(|user| view! {
                    <li>
                        {user.name.clone()}
                        <button
                            on:click={
                                let user = user.clone();

                                move |_| {
                                    on_delete(user.clone());
                                }
                            }
                        >"Delete"</button>
                    </li>
                }).collect::<Vec<_>>()
            }
        </ul>
    }
}

Closing happens from inside the modal via the injected close() (see the "Cancel" button above), from the controller with modal.close(), or simply by hitting Esc.

โธป

๐Ÿ“ API Reference

ModalCollector

Singleton component that manages modal state and rendering. We recommend it wraps your app root.

#[modal]

Proc macro that turns a normal component into one the ModalCollector can render. Your function keeps the (input, ctx, close) signature; the macro does the wiring.

use_modal!

Registers a modal and returns a typed controller:

RUST
// Without context
let modal = use_modal!(ModalComponent);

// With context
let modal = use_modal!(ModalComponent, context);

The controller exposes:

  • open(input) โ€” opens the modal with the provided input.

  • close() โ€” closes the modal.

Closing a modal

There's no single global close โ€” closing is always tied to a modal you've registered. You have three ways to dismiss one:

  • Inside the modal component, call the injected close() (the third argument of the signature).

  • From the controller returned by use_modal!, call modal.close().

  • Let the user press Esc โ€” the ModalCollector handles that for you.

Defining a modal

With both context and input

RUST
#[modal]
pub fn ModalComponent(input: Input, ctx: Context, close: fn()) -> impl IntoView {
    view! {
        // ...
    }
}

let modal = use_modal!(ModalComponent, context);
modal.open(input)

Skipping context

RUST
#[modal]
pub fn ModalComponent(input: Input, ctx: (), close: fn()) -> impl IntoView {
    view! {
        // ...
    }
}

let modal = use_modal!(ModalComponent);
modal.open(input)

Skipping input

RUST
#[modal]
pub fn ModalComponent(input: (), ctx: Context, close: fn()) -> impl IntoView {
    view! {
        // ...
    }
}

let modal = use_modal!(ModalComponent, context);
modal.open(())

Built-in behaviour

  • Accessibility โ€” proper ARIA attributes (role="dialog", aria-modal).

  • Keyboard navigation โ€” Esc closes the modal out of the box.

  • Portal rendering โ€” modals render in one place, and there's always exactly one on screen at a time (why would you want more than one modal up at once? ๐Ÿคจ).

  • Overlay โ€” semi-transparent backdrop with proper positioning.

  • Responsive โ€” full viewport coverage with centered content.

โธป

๐Ÿ› ๏ธ Requirements

  • Rust 2024 edition โ€” the crate targets the 2024 edition.

  • Leptos 0.8 โ€” built and tested against the 0.8 line.

โธป

๐Ÿ“ Repo & Contributions

๐Ÿ› ๏ธ Repo: https://github.com/dsplce-co/leptos-modal
๐Ÿ“ฆ Crate: https://crates.io/crates/leptos-modal

Contributions, issues, ideas? Hit us up ๐Ÿ–ค

โธป

๐Ÿ“„ License

MIT or Apache-2.0, at your option.