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.1"

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.1"
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

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.

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; }
}