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