Skip to content

Guides

Blocks: interactive components beyond the kit

A stepper, a range slider, a gallery, calendars and a kanban board, with the renox-blocks crate.

16 min read Edit this page

The UI kit covers forms, tables, sheets and pages. Some screens need more: a stepper for a quantity, a price filter with two handles, photos in a carousel, a month of bookings, a board of cards to drag. The renox-blocks crate adds eleven such components, as template macros built on the kit:

InputShowing dataScheduling and work
quantity: a steppergallery: photos in a carouselmonth_calendar: a month of events
range_slider: two handles on one trackhistory: a timelineavailability: resources against hours
keypad: a point-of-sale number padcompare_plans: pricing cards and a tablekanban: cards moved between columns
swatches: variant chips
datetime_range: a start and an end, with times

They're a separate crate, not part of the kit, so the kit stays small: a page loads the blocks' script only when it has a block, and then only the code of the blocks it has. examples/bikeshop uses every one of them; its /about/blocks page shows them side by side.

In this guide:

#Words you'll meet

WordWhat it means
blockOne of this crate's components: a template macro, plus a little JavaScript when it needs some.
macroA MiniJinja function that prints HTML, imported with {% from "…" import … %}.
plain fieldA normal form field (<input name="qty">), so the server reads it as any other.
live regionPart of a page a screen reader reads out when its text changes.

Note

Coming from Laravel: Laravel has no such components of its own; these are the kind of Blade or Livewire components a team writes once per project (a quantity stepper, a pricing table, a kanban board), here written once, on Renox's kit.

#Add it to your app

Add the crate next to renox, at the same version:

TOML
[dependencies]
renox = "1.0"
renox-blocks = "1.0"

Then add its module:

Rust
use renox::prelude::*;
use renox_blocks::Blocks;

pub fn app() -> App {
    App::new().module(Blocks::new())
}

The module adds the macros (renox-blocks/blocks.html) and serves the blocks' stylesheet and scripts under /_renox/blocks/. The blocks are built on the UI kit, so the page's layout needs {{ renox_ui() }} as for the kit's own components. Import what a page uses:

Template
{% from "renox-blocks/blocks.html" import quantity, swatches, gallery %}

#How blocks behave

Every block follows the same rules, so you can rely on them without reading each one:

  • Plain fields. The input blocks send normal form fields, so Valid<T> reads them, a failed submit shows the old input and the error under the block (the kit's rx-error place), and bag= names an error bag as for the kit's fields. Every value is checked on the server again: a block that greys something out is a help, not a guarantee.
  • The keyboard. Everything a pointer does, the keyboard does too; each block below says how.
  • Screen readers. Every control has a name, states are said in words (never by colour alone), and the kanban board announces each move.
  • The kit's look. Classes start with rx- like the kit's, colours, type, spacing and radii are the kit's tokens (--rx-*), and layouts are CSS grid, so themes, dark mode, the accent and the type scale follow the kit. Text meets WCAG AA contrast in light and dark.
  • Motion. The gallery slides, a moved card glides into place and a timeline fades in, with the browser's own Web Animations on transform and opacity. Under prefers-reduced-motion nothing moves.
  • CSP. No inline scripts or handlers: the blocks work under CSP=strict.
  • htmx. A block that htmx swaps into the page later (a sheet, a fragment) is set up when it arrives.

#Input blocks

#quantity

A number with − and + buttons, for how many of something (a cart line, a stock count). It sends one field, name, so read it as an integer:

Template
{{ quantity("qty", 1, min=1, max=10, label="Quantity") }}

The field is a native number input: Tab reaches it, the arrow keys step it and typing works. The buttons step it too (Tab skips them, since the arrow keys in the field do the same), grey out at the limits, and send input and change on each step, so htmx triggers and live validation see it. A cart that saves each change:

Template
{{ quantity("quantity", line.quantity, min=1, max=20, hide_label=true,
            label="Quantity of " ~ line.name, id="qty-" ~ line.id,
            attrs={"hx-patch": route('cart.update', line.id), "hx-trigger": "change delay:300ms"}) }}
OptionDefaultWhat it does
namethe field's name
value1the number at first (old input after a failed submit wins)
min, max, step0, none, 1the limits and the step (max=none for no upper limit)
label"Quantity"the field's label
hide_labelfalsekeep the label for screen readers only (a cart row)
attrs{}extra attributes for the input, e.g. htmx ones
hint, id, bagas for the kit's fields (id defaults to rx-{name})

#range_slider

A range with two handles, such as a price filter. It sends two fields, name_min and name_max:

Template
{{ range_slider("price_min", "price_max", 0, 1000000, step=25000,
                value_min=100000, value_max=500000, label="Price", format="money") }}

It is two native range inputs on one track, so each handle is a real slider: Tab reaches it, the arrow keys, Page Up/Down, Home and End move it, and a screen reader says its value in words. The handles never cross: the one moving stops at the other. Without JavaScript they are still two sliders that send both fields. Check min <= max on the server too (lte).

OptionDefaultWhat it does
name_min, name_maxthe two fields' names
min, max, stepstep 1the scale
value_min, value_maxthe scale's endswhere the handles start (old input wins)
label"Range"the group's legend
formatnone"money" (the money filter, APP_CURRENCY), "number", or none (as they are)
prefix, suffixnonearound the values when format is none, e.g. suffix=" km"
hint, id, bagas for the kit's fields

#keypad

A point-of-sale number pad that types into a field of the form, for a touch screen at a counter:

Template
{{ input("paid", "Amount paid", attrs={"inputmode": "numeric", "maxlength": "9"}) }}
{{ keypad("rx-paid", decimal=true, enter_label="Pay") }}

The keys are one Tab stop: the arrow keys move between them, Home and End go to the first and last, Enter or Space presses one. While the pad has the focus, typing digits, the decimal point, Backspace and Delete (clear) works as on its keys. Each press sends input to the field (its maxlength is respected); the Enter key submits the field's form (requestSubmit, so required and htmx run as for a click). The decimal key shows the page language's separator.

OptionDefaultWhat it does
targetthe id of the input it types into (the kit's input("paid", …) is rx-paid)
label"Number pad"what screen readers call the pad
decimalfalseadd a decimal-point key
zerostrueadd a "00" key
enter_label"Enter"the Enter key's text

#swatches

Variants (a frame size, a colour) as chips: a group of radio buttons, so one field, name:

Template
{{ swatches("size", "Frame size", [
     {"value": "S", "label": "S", "note": "150–165 cm"},
     {"value": "M", "label": "M"},
     {"value": "XL", "label": "XL", "disabled": true}
   ], selected="M", required=true) }}
{{ swatches("colour", "Colour", [
     {"value": "teal", "label": "Teal", "color": "#0b6e66"},
     {"value": "sand", "label": "Sand", "color": "#d8c7a3"}
   ], kind="colour", attrs={"hx-get": route('product.variant', product.id),
     "hx-trigger": "change", "hx-target": "#price", "hx-include": "closest form"}) }}

Tab reaches the group and the arrow keys choose, as for any radios. A chosen chip has an accent border and a tick (a shape, not only a colour); a disabled one is crossed out and "sold out" for screen readers. With kind="colour" each chip shows its colour beside the label, so the colour is never the only way to tell. Works without JavaScript.

OptionDefaultWhat it does
name, labelthe field's name and the group's legend
optionsa list of {value, label, note?, color?, disabled?}
selectednonethe value chosen at first (old input wins)
kind"size""size" (text chips) or "colour" (a colour beside the label)
attrs{}attributes for the group, e.g. htmx ones to ask the server for a price
required, hint, id, bagas for the kit's fields

#datetime_range

A start and an end, each a day and a time, such as a rental from Friday 10:00 to Sunday 18:00. It sends two fields, name_start and name_end, each YYYY-MM-DDTHH:MM:

Template
{{ datetime_range("starts_at", "ends_at", label="Rental", min="2026-10-06",
                  max="2026-12-31", step=30, opens=9, closes=19, required=true) }}

Each end is the kit's date_picker (type a day or pick it on the calendar) beside a select of times. The script writes the two fields that are sent and says how long the range is ("1 day 2 hours"); the end's calendar starts at the start's day, and an end before the start is pointed out at once. Read them as NaiveDateTime (Renox adds the seconds) and check the order on the server:

Rust
use renox::chrono::NaiveDateTime;
use renox::prelude::*;

#[derive(serde::Deserialize)]
struct RentalForm {
    starts_at: Option<NaiveDateTime>,
    ends_at: Option<NaiveDateTime>,
}

impl Validate for RentalForm {
    fn rules(&self, v: &mut Validator) {
        v.field("starts_at", &self.starts_at).required();
        v.field("ends_at", &self.ends_at)
            .required()
            .gt("starts_at", &self.starts_at);
    }
}

This block needs JavaScript (the sent fields are written by it).

OptionDefaultWhat it does
name_start, name_endthe two fields' names
labelnonethe group's legend
value_start, value_endnoneYYYY-MM-DDTHH:MM at first (old input wins)
min, maxnonethe first and last day that can be picked (YYYY-MM-DD)
step60minutes between the times offered
opens, closes8, 20the first and last hour offered
required, hint, id, bagas for the kit's fields

#Showing data

Photos as a carousel, with thumbnails under it and an enlarged view in the kit's sheet:

Template
{{ gallery([
     {"src": "/img/city-1.jpg", "alt": "The City 3 from the side", "caption": "Teal, size M"},
     {"src": "/img/city-2.jpg", "alt": "Its handlebar", "large": "/img/city-2-big.jpg"}
   ], id="city-photos", label="Photos of the City 3") }}

The arrows, the thumbnails, a swipe, or the keyboard (Left/Right, Home/End while the focus is in the gallery) change the photo, and a screen reader hears which photo of how many it is. Only the current photo can be reached; "Enlarge" opens it in a sheet (Escape closes it and the focus comes back). Without JavaScript the photos scroll sideways and the thumbnails are links.

OptionDefaultWhat it does
photosa list of {src, alt, thumb?, large?, caption?}: thumb a smaller file for the thumbnail, large the file shown enlarged
id"rx-gallery"the gallery's id (two on a page need their own)
label"Photos"what screen readers call it
enlargetruethe Enlarge button and its sheet

#history

A vertical timeline of what happened to something: an order, a repair, a document.

Template
{{ history([
     {"time": "2026-10-06T09:12:00Z", "title": "Checked in", "body": "Brakes **squeal**.", "kind": "info"},
     {"time": "2026-10-06T11:40:00Z", "title": "Ready for pickup", "kind": "success", "by": "Marta"}
   ], label="Service history", date_format="%d %b %H:%M") }}

An ordered list, each event with its time first. kind sets the marker's colour and its icon, and screen readers hear it in words ("done", "needs attention"). body is Markdown (the markdown filter: raw HTML is shown as text). The events fade in one after the other the first time the list scrolls into view.

OptionDefaultWhat it does
itemsa list of {time, title, body?, kind?, by?, when?}, in the order shown
timean RFC 3339 time or a date, shown with the date filter
whenreplaces the shown time, e.g. "2 hours ago" from the since filter
kindnoneinfo, success, warning or error; none is a plain dot
label"History"the list's name for screen readers
date_format"%Y-%m-%d %H:%M"chrono's format for the times
idnonethe list's id

#compare_plans

Pricing cards side by side, then a table comparing every feature, with one plan highlighted:

Template
{{ compare_plans([
     {"key": "basic", "name": "Basic", "price": 2900, "interval": "month",
      "description": "For weekend rides.", "perks": ["A check-up a month"], "url": "/plans/basic"},
     {"key": "rider", "name": "Rider", "price": 4900, "interval": "month", "url": "/plans/rider"},
     {"key": "fleet", "name": "Fleet", "price_label": "Ask us", "url": "/contact"}
   ], [
     {"label": "Visits a month", "values": {"basic": "1", "rider": "2", "fleet": "Unlimited"}},
     {"label": "Pickup and delivery", "values": {"basic": false, "rider": true, "fleet": true}}
   ], highlight="rider", label="Service plans") }}

The cards sit on a CSS grid (one column on a phone, up to four). The highlighted card has the accent border and a "Most popular" badge, and its column in the table is tinted. In the table true is a tick and false a dash, with "Included" and "Not included" for screen readers. On a phone the table scrolls sideways inside its frame (the keyboard can scroll it too), with the feature names staying put.

OptionDefaultWhat it does
plansa list of {key, name, price, interval?, description?, perks?, url?, cta?, price_label?}: price goes through the money filter, price_label replaces it, interval is "month" or "year", url is the card's button (cta its text)
features[]the table's rows: {label, values: {plan key: true, false or text}}
highlightnonethe key of the plan to stand out
highlight_label"Most popular"the badge's text
label"Plans"the section's name for screen readers
tabletruefalse shows only the cards
id"rx-plans"the section's id

#Scheduling and work

#month_calendar

One month as a grid of days with what happens on each, and links to the months around it. On a phone it becomes a list of the days that have something.

Template
{{ month_calendar("2026-10", [
     {"date": "2026-10-06", "time": "09:30", "title": "Tune-up · City 3", "url": "/visits/4", "kind": "info"},
     {"date": "2026-10-06", "title": "Rental: Trail 5", "kind": "success"}
   ], url=route('visits.index'), today="2026-10-06", label="Service visits") }}

The days are an ordered list on a seven-column grid, so a screen reader reads them in order, each with its full weekday and "Today" when it is. The previous and next months are links (?month=YYYY-MM on url), so each month has its own address; pair them with hx-boost and hx-select to change only the calendar. The template has no clock: pass today from the handler. The month is worked out in the template, so no Rust helper is needed.

OptionDefaultWhat it does
month"YYYY-MM"
events[]a list of {date, title, time?, url?, kind?}; date is a day or a date-time (its day counts); kind as for the kit's badges
urlnonethe page's address; without it there are no month links
param"month"the query parameter of the month links (url may already have a query)
todaynone"YYYY-MM-DD", marked in the grid
first_day11 for Monday, 0 for Sunday
labelnonethe calendar's name for screen readers
heading"h2"the month title's heading level
id"rx-month-{month}"the section's id

#availability

A timeline of resources (bikes, rooms, people) against hours or days, with the booked and free slots; a free slot is a link or a small form that books it:

Template
{{ availability(["09:00", "10:00", "11:00", "12:00"], [
     {"label": "Trail 5", "note": "M · #12", "slots": [
        {"state": "booked", "span": 2, "title": "Ana R."},
        {"state": "free", "url": "/rent?bike=12&at=11"},
        {"state": "free", "action": "/rentals", "fields": {"bike": 12, "at": "12:00"}}]}
   ], label="Bikes free today", corner="Bike") }}

It is a table: each resource is a row heading and each time a column heading, so a screen reader reads "Trail 5, 11:00, Book". Every free slot says what it books ("Book Trail 5 at 11:00"). Booked slots are striped and closed ones dashed, each with its word, so no state rests on colour alone; a legend says what they mean. On a phone the table scrolls sideways inside its frame with the resources staying put. A slot with action is a form POSTed with its fields and the CSRF field, so booking needs no JavaScript.

OptionDefaultWhat it does
columnsthe time labels across the top
rowsa list of {label, note?, slots}; slots fill the row from the left
a slot{state, span?, title?, url?, action?, fields?}: state is free, booked or closed; span how many columns it covers; title who has it
label"Availability"the table's caption for screen readers
corner"Resource"the top-left heading
id"rx-availability"the frame's id

#kanban

Columns of cards moved between columns by dragging, or with the keyboard, each move sent to the server with htmx:

Template
{{ kanban("jobs", [
     {"key": "waiting", "title": "Waiting", "cards": [
        {"id": 7, "title": "Tune-up", "subtitle": "City 3 · Ana", "badge": "Today", "badge_kind": "warning"}]},
     {"key": "working", "title": "In the stand", "cards": []},
     {"key": "ready", "title": "Ready", "cards": []}
   ], url=route('jobs.move'), values={"store": 2}, label="Workshop jobs") }}

Each move is an htmx POST to url with the fields card (the card's id), column (the new column's key) and position (0 for the top), plus values and the CSRF header htmx gets from Renox. Answer 2xx to keep the move; any other status puts the card back and says so:

Rust
use renox::prelude::*;

#[derive(serde::Deserialize)]
struct Move {
    card: Option<i64>,
    column: Option<String>,
    position: Option<i64>,
}

impl Validate for Move {
    fn rules(&self, v: &mut Validator) {
        v.field("card", &self.card).required();
        v.field("column", &self.column)
            .required()
            .one_of(&["waiting", "working", "ready"]);
        v.field("position", &self.position).required().min(0);
    }
}

/// Saves the card's column and order (after checking the person may move it).
async fn move_card(Valid(_form): Valid<Move>) -> StatusCode {
    StatusCode::NO_CONTENT
}

With a mouse, drag a card anywhere; with a finger or a pen, drag it by its handle (the dots), so a swipe elsewhere still scrolls the page. With the keyboard, Tab to a card, then Space or Enter picks it up, the arrow keys move it (Up/Down in its column, Left/Right to the next column), Space or Enter drops it and Escape puts it back; without a card picked up the arrow keys move between cards. Every step is announced. A card's own link (url) still opens it. After the server answered, the board sends rx:kanban-moved with {card, column, position, ok} in detail. Without JavaScript the board is a read-only list.

OptionDefaultWhat it does
idthe board's id
columnsa list of {key, title, cards}
a card{id, title, subtitle?, badge?, badge_kind?, url?}
urlwhere each move is POSTed
label"Board"the board's name for screen readers
values{}extra fields sent with every move

kanban_card(card) prints one card, for a fragment that adds a card to a board already on the page; the board sets it up when htmx swaps it in.

#Texts in other languages

The blocks' texts are English unless your app's lang files have them, under the same keys (renox_blocks::TEXTS lists them all, with the English):

JSON
{
  "blocks": {
    "quantity": { "label": "Cantidad", "increase": "Uno más (:label)", "decrease": "Uno menos (:label)" },
    "gallery": { "next": "Foto siguiente", "previous": "Foto anterior", "position": "Foto :n de :total" },
    "calendar": { "today": "Hoy", "month_10": "Octubre" }
  }
}

:name placeholders are filled as the t function fills them. Texts you pass to a macro (label=, enter_label=, a plan's cta) are yours, so translate them with t as usual.

#Changing the look

The blocks use the kit's tokens, so a theme that changes the accent, the surfaces or the radii changes them too. To change one block's markup, copy the macros into your app as resources/views/renox-blocks/blocks.html: your file replaces the crate's.

#How the files load

The first block on a page prints two tags: the blocks' stylesheet and blocks.js, a JavaScript module. The module looks at the page (and at whatever htmx swaps in later) and imports a block's code only when the page has that block: a product page with a gallery and a stepper loads gallery.js and quantity.js, nothing else. swatches, compare_plans, month_calendar and availability need no code at all.

The files are compiled into the crate, with no library and no build step. The app serves them itself from /_renox/blocks/, under names that carry a hash of their content, with a year-long cache and no session cookie. A browser runs each module once per page.

#Testing

The input blocks are plain form fields, so test your handlers by posting what the browser would send:

Rust
use renox::prelude::*;
use renox::testing::TestApp;

#[derive(serde::Deserialize, Validate)]
struct CartLine {
    #[validate(required, between(1, 20))]
    quantity: Option<i64>,
    #[validate(required, one_of(&["S", "M", "L"]))]
    size: Option<String>,
}

struct Cart;

impl Module for Cart {
    fn name(&self) -> &'static str { "cart" }

    fn routes(&self) -> Routes {
        Routes::new().post("/cart", |Valid(line): Valid<CartLine>| async move {
            format!("{} × {}", line.quantity.unwrap_or(0), line.size.unwrap_or_default())
        })
    }
}

#[renox::test]
async fn a_line_is_added() {
    let app = TestApp::new(App::new().module(renox_blocks::Blocks::new()).module(Cart)).await;
    app.post("/cart", &[("quantity", "2"), ("size", "M")])
        .await
        .assert_ok()
        .assert_see("2 × M");
    // The stepper stops at its max in the browser; the server stops it too.
    app.htmx()
        .post("/cart", &[("quantity", "21"), ("size", "M")])
        .await
        .assert_invalid("quantity");
}

TestApp doesn't run JavaScript, so check the blocks themselves in a browser (see testing.md); the crate's own checks are tests/browser/blocks.test.mjs.