The leptosbook Cookbook

leptosbook gives Leptos a small set of primitives for paginated, gesture-driven interfaces — image carousels, onboarding flows, slide decks, swipeable dashboards, wizards, anything where the user moves through a sequence one screen at a time.

This cookbook is task-focused. Each chapter solves one concrete problem with a complete, copy-pasteable snippet. If you want the full prop-by-prop reference, read the Guide instead; if you want runnable apps, see the examples/ directory.

The one component you need to know

Everything starts with Folio: hand it a Signal<Vec<T>> and a render function, and it shows one item at a time with swipe, trackpad, mouse, and keyboard navigation built in. Descendant components read or drive that navigation through use_folio_context().

#![allow(unused)]
fn main() {
use leptos::prelude::*;
use leptosbook::prelude::*;

#[component]
fn Carousel(slides: Signal<Vec<String>>) -> impl IntoView {
    view! {
        <Folio items=slides render=|s: String| view! { <p>{s}</p> }>
            <FolioNav/>
        </Folio>
    }
}
}

Targets Leptos 0.9. Add it with:

[dependencies]
leptos = { version = "0.9.0-alpha", features = ["csr"] }
leptosbook = "0.2"

Getting started

This recipe builds the smallest complete leptosbook app: a swipeable set of cards with prev/next buttons.

1. Dependencies

[dependencies]
leptos = { version = "0.9.0-alpha", features = ["csr"] }
leptosbook = "0.2"
console_error_panic_hook = "0.1"

2. The app

use leptos::mount::mount_to_body;
use leptos::prelude::*;
use leptosbook::prelude::*;

#[derive(Clone)]
struct Card { title: &'static str, body: &'static str }

#[component]
fn App() -> impl IntoView {
    // Folio wants a `Signal<Vec<T>>`. For a static list, derive one.
    let cards = Signal::derive(|| vec![
        Card { title: "One",   body: "Swipe, drag, or press → to advance." },
        Card { title: "Two",   body: "Press ← (or swipe back) to return." },
        Card { title: "Three", body: "The counter tracks your place." },
    ]);

    view! {
        <Folio
            items=cards
            render=|c: Card| view! {
                <section><h1>{c.title}</h1><p>{c.body}</p></section>
            }
        >
            <FolioNav/>
        </Folio>
    }
}

fn main() {
    console_error_panic_hook::set_once();
    mount_to_body(|| view! { <App/> });
}

3. Run it

leptosbook examples use Trunk:

trunk serve --open

You need an index.html next to Cargo.toml:

<!DOCTYPE html>
<html>
  <head><meta charset="utf-8" /><link data-trunk rel="rust" /></head>
  <body></body>
</html>

That's it. You now have swipe, trackpad, mouse-drag, and arrow-key navigation for free.

What just happened

  • items is a signal, so the folio re-renders if the list changes.
  • render is called only for the visible card, once per turn.
  • <FolioNav/> is a descendant, so it reads the navigation state from context automatically — no props to thread through.

An image carousel

A full-bleed image carousel with a caption and a counter — the classic gallery pattern.

#![allow(unused)]
fn main() {
use leptos::prelude::*;
use leptosbook::prelude::*;

#[derive(Clone)]
struct Photo { url: &'static str, caption: &'static str }

#[component]
fn Gallery() -> impl IntoView {
    let photos = Signal::derive(|| vec![
        Photo { url: "/img/1.jpg", caption: "Sunrise over the bay" },
        Photo { url: "/img/2.jpg", caption: "City lights" },
        Photo { url: "/img/3.jpg", caption: "Quiet forest trail" },
    ]);

    view! {
        <div class="gallery">
            <Folio
                items=photos
                render=|p: Photo| view! {
                    <figure class="slide">
                        <img src=p.url alt=p.caption/>
                        <figcaption>{p.caption}</figcaption>
                    </figure>
                }
            >
                <FolioNav/>
            </Folio>
        </div>
    }
}
}
.gallery { height: 70vh; display: flex; flex-direction: column; }
.slide { margin: 0; height: 100%; }
.slide img { width: 100%; height: 100%; object-fit: cover; }
.slide figcaption {
  position: absolute; bottom: 0; left: 0; right: 0;
  padding: 1rem; background: linear-gradient(transparent, rgba(0,0,0,.6));
  color: white;
}

Tips

  • Give the carousel a fixed height; the page slot fills its parent.
  • object-fit: cover keeps images from distorting across aspect ratios.
  • Want lazy loading? Add loading="lazy" — but remember render only runs for the visible slide, so off-screen images aren't in the DOM until you reach them.

An onboarding flow

A first-launch "welcome tour" with a few steps, dots that track progress, and a footer that turns into a Get started button on the last step.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use leptos::prelude::*;
use leptosbook::prelude::*;

#[derive(Clone)]
struct Step { icon: &'static str, title: &'static str, blurb: &'static str }

const STEPS: &[Step] = &[
    Step { icon: "👋", title: "Welcome",   blurb: "A 20-second tour." },
    Step { icon: "⚡", title: "Fast",      blurb: "Keyboard and gestures everywhere." },
    Step { icon: "🚀", title: "You're set", blurb: "Swipe back any time." },
];

#[component]
fn Onboarding(on_finish: Arc<dyn Fn() + Send + Sync>) -> impl IntoView {
    let steps = Signal::derive(|| STEPS.to_vec());
    view! {
        <Folio
            items=steps
            render=|s: Step| view! {
                <section class="step">
                    <div class="step-icon">{s.icon}</div>
                    <h1>{s.title}</h1>
                    <p>{s.blurb}</p>
                </section>
            }
        >
            <Dots/>
            <Footer on_finish=on_finish/>
        </Folio>
    }
}
}

The footer reads FolioContext to know whether it's on the last step:

#![allow(unused)]
fn main() {
#[component]
fn Footer(on_finish: Arc<dyn Fn() + Send + Sync>) -> impl IntoView {
    let ctx = use_folio_context();
    let go_next = ctx.go_next.clone();
    let last = move || ctx.current_page.get() + 1 >= ctx.total_pages.get();

    view! {
        <footer class="onb-footer">
            <Show
                when=last
                fallback=move || {
                    let go_next = go_next.clone();
                    view! { <button on:click=move |_| go_next()>"Next"</button> }
                }
            >
                {
                    let on_finish = on_finish.clone();
                    view! { <button class="primary" on:click=move |_| on_finish()>"Get started"</button> }
                }
            </Show>
        </footer>
    }
}
}

For the Dots component, see the next recipe, Tabs and progress dots.

Why pass on_finish in?

The folio owns navigation state, not application state. "Onboarding is complete" belongs to your app, so hand the component a callback rather than trying to stuff app logic into the folio.

Custom navigation from context

FolioNav is convenient, but sometimes you want your own controls — a floating button, a step label, a "skip to end" link. Any descendant of <Folio> can call use_folio_context() and drive navigation directly.

The context

#![allow(unused)]
fn main() {
pub struct FolioContext {
    pub current_page: ReadSignal<usize>,        // reactive index
    pub total_pages:  Signal<usize>,            // reactive length
    pub go_next: Arc<dyn Fn() + Send + Sync>,   // forward one (clamped)
    pub go_prev: Arc<dyn Fn() + Send + Sync>,   // back one (clamped)
    pub go_to:   Arc<dyn Fn(usize) + Send + Sync>, // jump (clamped)
    pub anim_epoch: ReadSignal<u64>,            // ticks each turn
    pub last_dir:   ReadSignal<Option<TurnDir>>,// Forward / Backward
}
}

You navigate by calling the closures and read state from the signals.

A bespoke control bar

#![allow(unused)]
fn main() {
use leptos::prelude::*;
use leptosbook::prelude::*;

#[component]
fn ControlBar() -> impl IntoView {
    let ctx = use_folio_context();
    let (go_prev, go_next, go_to) =
        (ctx.go_prev.clone(), ctx.go_next.clone(), ctx.go_to.clone());

    let at_start = move || ctx.current_page.get() == 0;
    let at_end   = move || ctx.current_page.get() + 1 >= ctx.total_pages.get();

    view! {
        <div class="control-bar">
            <button on:click=move |_| go_prev() disabled=at_start>"‹"</button>
            <span>{move || format!("{} of {}",
                ctx.current_page.get() + 1, ctx.total_pages.get())}</span>
            <button on:click=move |_| go_next() disabled=at_end>"›"</button>
            <button class="skip" on:click=move |_| {
                let last = ctx.total_pages.get().saturating_sub(1);
                go_to(last);
            }>"Skip to end"</button>
        </div>
    }
}
}

Reacting to a turn

Use anim_epoch or current_page in an effect to fire side effects — analytics, autoplay pausing, lazy fetches:

#![allow(unused)]
fn main() {
let ctx = use_folio_context();
Effect::new(move |_| {
    let page = ctx.current_page.get();   // re-runs on every turn
    track_event("slide_view", page);
});
}

And last_dir tells you the direction of the most recent turn:

#![allow(unused)]
fn main() {
let dir = move || match ctx.last_dir.get() {
    Some(TurnDir::Forward)  => "→",
    Some(TurnDir::Backward) => "←",
    None => "·",
};
}

Clone before you move

Each handler closure that uses a context closure needs its own clone — they're Arcs, so cloning is cheap:

#![allow(unused)]
fn main() {
let go_next = ctx.go_next.clone();          // once per handler
view! { <button on:click=move |_| go_next()>"Next"</button> }
}

Tabs and progress dots

leptosbook ships FolioTabs for a labeled tab strip, and it's easy to hand-roll progress dots. Both follow the same idea: read current_page from context, and jump with go_to.

FolioTabs, wired to a folio

FolioTabs is intentionally decoupled from context — you pass it active and on_select. That makes it reusable outside a folio, but inside one you wire it up in three lines:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use leptos::prelude::*;
use leptosbook::prelude::*;

#[component]
fn SectionTabs() -> impl IntoView {
    let ctx = use_folio_context();
    let go_to = ctx.go_to.clone();
    let on_select: Arc<dyn Fn(usize) + Send + Sync> = Arc::new(move |i| go_to(i));

    view! {
        <FolioTabs
            tabs=vec!["Overview", "Details", "Pricing"]
            active=ctx.current_page
            on_select=on_select
        />
    }
}
}

tabs is a Vec<&'static str>, so the labels are fixed at compile time — ideal for a known set of sections.

Hand-rolled progress dots

When you just want minimal dots that scale to any length, render straight from the context:

#![allow(unused)]
fn main() {
#[component]
fn Dots() -> impl IntoView {
    let ctx = use_folio_context();
    let go_to = ctx.go_to.clone();

    view! {
        <div class="dots">
            {move || {
                let cur = ctx.current_page.get();
                let go_to = go_to.clone();
                (0..ctx.total_pages.get()).map(move |i| {
                    let go_to = go_to.clone();
                    view! {
                        <button
                            class:on=move || i == cur
                            on:click=move |_| go_to(i)
                            aria-label=format!("Go to item {}", i + 1)
                        />
                    }
                }).collect_view()
            }}
        </div>
    }
}
}
.dots { display: flex; gap: .5rem; justify-content: center; padding: 1rem; }
.dots button {
  width: .6rem; height: .6rem; border-radius: 50%;
  border: none; background: rgba(255,255,255,.3); cursor: pointer;
  transition: background .2s, transform .2s;
}
.dots button.on { background: white; transform: scale(1.3); }

Which to use?

  • FolioTabs — a few, named sections (Overview / Details / Pricing).
  • Hand-rolled dots — many, unnamed items (a 12-photo gallery), or when you want full control over the markup and ARIA.

Working with gestures

Folio handles touch, mouse-drag, trackpad, and keyboard out of the box. This recipe covers tuning that behavior and reusing the recognizer on your own elements.

What Folio recognizes

InputResult
Swipe / drag / scroll rightnext
Swipe / drag / scroll leftprev
ArrowRight / ArrowDown / PageDown / Spacenext
ArrowLeft / ArrowUp / PageUpprev

Input aimed at a form field never turns the page: keys typed into an input, textarea, select or contenteditable, and drags that start in one, are left to the field. Space on a focused button or link activates it instead of paging, and a key your own handler has already prevent_default()ed is ignored.

To freeze navigation entirely while a form is open, see Forms inside a page.

Tuning sensitivity

A turn fires once pointer travel exceeds threshold (px). Raise it to require a more deliberate swipe; lower it for a hair-trigger:

#![allow(unused)]
fn main() {
<Folio items=items render=render threshold=120.0>
    <FolioNav/>
</Folio>
}

Reusing the recognizer

The gesture math is exposed as a free function so you can build your own swipeable surfaces — a dismissible card, a drawer, a rating slider:

#![allow(unused)]
fn main() {
use leptos::ev;
use leptos::prelude::*;
use leptosbook::{resolve, SwipeDir};

#[component]
fn SwipeToDismiss(on_dismiss: impl Fn() + 'static) -> impl IntoView {
    let start = RwSignal::new(Option::<(f64, f64)>::None);

    let down = move |e: ev::MouseEvent|
        start.set(Some((e.client_x() as f64, e.client_y() as f64)));

    let up = move |e: ev::MouseEvent| {
        if let Some((sx, sy)) = start.get() {
            start.set(None);
            let (dx, dy) = (e.client_x() as f64 - sx, e.client_y() as f64 - sy);
            if let Some(SwipeDir::Left | SwipeDir::Right) = resolve(dx, dy, 80.0) {
                on_dismiss();
            }
        }
    };

    view! { <div on:mousedown=down on:mouseup=up>"swipe me away"</div> }
}
}

resolve(dx, dy, threshold) returns the dominant SwipeDir once it clears the threshold, or None if the movement was too small. Horizontal wins ties.

A note on SwipeConfig

SwipeConfig (threshold / keyboard / mouse) is a builder for your own handlers:

#![allow(unused)]
fn main() {
use leptosbook::SwipeConfig;
let cfg = SwipeConfig::default().threshold(100.0).no_keyboard();
}

It is not yet consumed by <Folio> — Folio currently takes only the threshold prop and always listens for all input types. Wiring SwipeConfig into Folio (to, say, disable keyboard nav) is on the roadmap.

Forms inside a page

Pages often hold a small form: a reply box, a rating, a note. Two things keep the folio from fighting it.

Form fields keep their input

You don't have to do anything for this part. <Folio> ignores input aimed at a form field:

  • keys typed into an input, textarea, select or contenteditable element (so Space and the arrow keys edit text instead of turning the page);
  • mouse drags and touch swipes that start in one (text selection, not a swipe);
  • Space on a focused button or link, which activates it;
  • any key your own handler already called prevent_default() on.

Lock the page while the form is open

Focus can leave the field (onto a Submit button, say) and a phone swipe can land outside it. If turning the page would throw away what the user typed, hold a lock with use_folio_lock for as long as the form is open:

#![allow(unused)]
fn main() {
use leptos::prelude::*;
use leptosbook::prelude::*;

#[component]
fn NoteForm() -> impl IntoView {
    let (open, set_open) = signal(false);
    let (text, set_text) = signal(String::new());

    // Locked exactly while `open` is true; released on unmount.
    use_folio_lock(open);

    view! {
        <Show
            when=move || open.get()
            fallback=move || view! {
                <button on:click=move |_| set_open.set(true)>"Add a note"</button>
            }
        >
            <textarea
                prop:value=text
                on:input=move |e| set_text.set(event_target_value(&e))
            />
            <button on:click=move |_| set_open.set(false)>"Done"</button>
        </Show>
    }
}
}

Render it from your Folio's render closure; like use_folio_context, the hook must run inside a <Folio>.

While any lock is held:

  • keys, swipes, drags and the trackpad don't turn the page;
  • go_next, go_prev and go_to are no-ops;
  • <FolioNav/> disables both buttons.

Locks nest, so several forms can each hold one; the folio unlocks when the last is released. Read use_folio_context().locked to disable your own navigation controls the same way.

Theming and styling

leptosbook ships sensible defaults in the FOLIO_CSS constant and injects them automatically. You can layer on top, or take over completely.

Layer on top (default)

Leave inject_css=true (the default) and just write CSS that targets the leptosbook classes — later rules win:

#![allow(unused)]
fn main() {
<Folio items=items render=render>   // inject_css defaults to true
    <FolioNav/>
</Folio>
}
/* your stylesheet, loaded after the component */
.folio-nav-btn {
  background: #2563eb;
  color: white;
  border: none;
  padding: .5rem 1.25rem;
  border-radius: 6px;
}
.folio-nav-btn:hover:not(:disabled) { background: #1d4ed8; }
.folio-nav-counter { font-variant-numeric: tabular-nums; }

Take over completely

Set inject_css=false and supply your own — optionally starting from the defaults via the FOLIO_CSS constant:

#![allow(unused)]
fn main() {
<Folio inject_css=false items=items render=render>
    <style>{leptosbook::FOLIO_CSS}</style>   // start from defaults…
    <style>{ "/* …then your overrides */" }</style>
    <FolioNav/>
</Folio>
}

Or omit FOLIO_CSS entirely for a clean slate.

Class reference

ClassElement
.folioOuter focusable container
.folio-page-slotClipping viewport for the page
.folio-pageThe current page (absolutely positioned)
.folio-enter-left / .folio-enter-rightDirection-aware slide-in
.folio-emptyWrapper for empty_fallback
.folio-navFolioNav container
.folio-nav-btn / :disabledNav buttons
.folio-nav-counterThe n / total text
.folio-tabsFolioTabs container
.folio-tab / .folio-tab.activeIndividual tab

Customizing the transition

The slide-in direction is chosen from last_dir: forward turns get .folio-enter-left, backward turns get .folio-enter-right. Override the keyframes to change the feel — here, a cross-fade instead of a slide:

.folio-enter-left, .folio-enter-right {
  animation: fade .2s ease both;
}
@keyframes fade { from { opacity: 0 } to { opacity: 1 } }

Respecting reduced motion

Be kind to users who ask for less animation:

@media (prefers-reduced-motion: reduce) {
  .folio-enter-left, .folio-enter-right { animation: none; }
}