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
itemsis a signal, so the folio re-renders if the list changes.renderis 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: coverkeeps images from distorting across aspect ratios.- Want lazy loading? Add
loading="lazy"— but rememberrenderonly 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
| Input | Result |
|---|---|
| Swipe / drag / scroll right | next |
| Swipe / drag / scroll left | prev |
ArrowRight / ArrowDown / PageDown / Space | next |
ArrowLeft / ArrowUp / PageUp | prev |
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,selectorcontenteditableelement (soSpaceand the arrow keys edit text instead of turning the page); - mouse drags and touch swipes that start in one (text selection, not a swipe);
Spaceon 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_prevandgo_toare 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
| Class | Element |
|---|---|
.folio | Outer focusable container |
.folio-page-slot | Clipping viewport for the page |
.folio-page | The current page (absolutely positioned) |
.folio-enter-left / .folio-enter-right | Direction-aware slide-in |
.folio-empty | Wrapper for empty_fallback |
.folio-nav | FolioNav container |
.folio-nav-btn / :disabled | Nav buttons |
.folio-nav-counter | The n / total text |
.folio-tabs | FolioTabs container |
.folio-tab / .folio-tab.active | Individual 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; }
}