Skip to content

Text and content

Reading pages such as articles, profiles and listings are built from a few small components. They are plain HTML rendered on the server, with no script, and every text goes through the locale files or is escaped.

Brewing green tea

12 May 20265 min readGuides

Water just off the boil and two minutes of steeping keep the taste soft.

Times are a starting point.

Heading

Heading::h2(text) to Heading::h6(text). The screen title is the page's only h1, so there is no h1 here: pick the level that follows the outline, not the size you want. .id("anchor") lets links point at it.

Heading::h2(t!("shop.new_arrivals")).id("new")

Paragraph

A paragraph of escaped text. .muted() uses the secondary text color and .small() the small font size. Long words wrap.

Paragraph::new(t!("shop.intro"))
Paragraph::new(t!("shop.tax_note")).muted().small()

MetaLine

Short facts on one line, such as a date, a reading time and a category. The separator (a middle dot unless .separator("/") says otherwise) is hidden from screen readers, and empty parts are left out.

MetaLine::new().part(article.published.format(ctx.locale())).part(t!("blog.read_time")).part(article.category.clone())

PrevNext

Links to the previous and the next item, with rel="prev" and rel="next". They open without a page reload. The words "Previous" and "Next" come from the framework's texts; .prev_title and .next_title add the item's title under them. The arrows turn around in right to left languages, and on phones the two links stack.

PrevNext::new()
    .prev(article, ArticleParams { id: prev.id })
    .prev_title(prev.title.clone())
    .next(article, ArticleParams { id: next.id })
    .next_title(next.title.clone())

AutoGrid

Children in a grid whose column count follows the available width: as many columns of at least min pixels (240 unless set) as fit, and one column on a phone. The browser lays it out with repeat(auto-fill, minmax(min(240px, 100%), 1fr)), so there are no breakpoints and no script. It holds any nodes: cards, panels, grids. The name keeps it apart from DataGrid, which shows rows of data.

AutoGrid::new()
    .min(220)
    .gap(4)
    .add(Card::new().title(t!("home.orders")))
    .add(Card::new().title(t!("home.stock")))
    .add(Card::new().title(t!("home.people")))

Orders

Stock

People

Invoices

Reports

List

Rows one under the other in a <ul>. Each ListItem has a title and may have a small leading image (the same safe addresses as card images: a path, https: or data:image/), a subtitle and short meta text at the end. With .link(screen, params) the whole row is one link, so it is one stop in the tab order with a visible focus ring, and it opens without a page reload. Long text wraps; on phones the meta text moves under the title, and right to left pages mirror the row.

List::new() holds the items it is given. List::of(action) loads rows through the same kind of action a DataGrid uses and pages like a card grid: 20 rows with a "Show more" button unless paging says otherwise, sort options, server side search with .search(text), and the action's access checked for every page.

List::new()
    .title(t!("team.title"))
    .item(ListItem::new("Ada Lovelace").subtitle(t!("team.math")))
    .item(ListItem::new("Alan Turing").subtitle(t!("team.computing")).meta(t!("team.lead")))

List::of(search_people)
    .title(t!("people.title"))
    .item(|p: &Person| {
        ListItem::new(p.name.clone())
            .image(p.photo.clone())
            .subtitle(p.city.clone())
            .meta(p.joined.format(&locale))
            .link(person, PersonParams { id: p.id })
    })
    .sort_option(t!("people.by_name"), Person::name, false)
    .paging(Paging::pages(20))

Avatar

A round picture of a person or a team, 32, 40 or 64 pixels (AvatarSize::Small, Medium, Large). The name is required: it is the alternative text and the source of the initials shown when there is no picture, or when the picture is not on a host the app's img-src allows (the same rule as Image). Initials follow the language's capitals, so Turkish i becomes İ. When the name is written right next to the avatar, .decorative() hides it from screen readers so they do not read the name twice.

Avatar::new(p.name.clone()).image(p.photo.clone()).size(AvatarSize::Large)
Avatar::new(p.name.clone()).size(AvatarSize::Small).decorative()

Spoiler

Content hidden until the reader opens it, such as the answer to a question. It is a native disclosure (<details> and <summary>), so it works with the keyboard and screen readers without any script, and the content stays out of the reading order until it is opened. The label comes from the app.

Spoiler::new(t!("quiz.show_answer")).add(Paragraph::new(answer.clone()))
Show the answer

Water at about 80 degrees and two minutes of steeping.

Chips

A row of small rounded links that wrap onto more lines. Chips::links(label) holds plain links to screens, such as the tags of an article, in a nav named by the label. Chips::filter(label, screen, params) makes toggle buttons over a screen param: each chip is a link to the same screen with its value added to or taken out of the param, so the server renders aria-pressed from the params it got and nothing is kept in the browser. Pressing a chip opens the new address through nav.js without a page reload, and focus and the scroll position stay on the chip. One value is pressed at a time unless .multiple() is called; values in the params that are not options are ignored. Keyboard: Tab moves between chips, Enter or Space presses one. A pressed chip also shows a check mark, so the state does not rest on color. The script (chips.js, under 1 KB) loads only on pages with filter chips.

#[screen(route = "/articles", public)]
pub fn articles(p: ArticleParams) -> Screen {
    Screen::new(t!("blog.articles")).body(
        Chips::filter(t!("blog.tags"), articles, |tags| ArticleParams { tags: tags.join(",") })
            .current(p.tags.split(','))
            .option("guide", t!("blog.guides"))
            .option("news", t!("blog.news"))
            .multiple(),
    )
}

Hero

A large section at the top of a page: a background image with a title, a line of text and actions such as buttons. It is static by default. .slide(title) adds another slide, and .text, .image and .action always set the last one. With several slides it follows the W3C APG carousel pattern: slide picker buttons, each slide a group named "2 of 3", and the picked slide announced politely. Slides change only when the user picks one unless the app calls .rotate(seconds) (4 to 30). Then a pause and play button comes first (WCAG 2.2.2), rotation stops while the pointer is over the hero or focus is inside it and while the tab is hidden, and it never starts when the user prefers reduced motion; the slides are not announced while they move. Without script the first slide shows.

The image follows the Image rules (a path of the app or an https address on a host of the app's img-src). It is decoration behind the text, so its alternative text is empty: put what matters into the title and the text, which sit on a dark gradient for contrast. The first slide's image loads with high priority for a fast first paint, the others lazily. The script (hero.js, 1.6 KB gzip) loads only on pages with slides.

Hero::new(t!("home.fresh"))
    .text(t!("home.fresh_text"))
    .image("/img/hero/kitchen.jpg")
    .action(ActionButton::new(t!("home.order"), start_order, ()))
    .slide(t!("home.new_teas"))
    .image("/img/hero/teas.jpg")
    .rotate(8)

Fresh every morning

Order before nine and it is at your door by noon.

Reactions

A row of reaction buttons chosen by the app, each with a key, a name and a symbol such as an emoji, and its count. Reactions::of(action, target) names the item the reactions are about; the target is set by the screen and never read from the browser. Pressing a button posts the reaction to POST /_zeyn/data, which checks CSRF, counts against the write rate limit, opens the screen, checks the action's permission and refuses a reaction outside the set before the action runs. The action gets a ReactionPress (target, reaction, pressed), stores it and returns the new ReactionState (counts and the user's own reactions), and the counts and aria-pressed states update in place. Each button is a toggle button named by the reaction's name and its count; the symbol is hidden from screen readers. .state(...) gives the counts for the first render, so they show without script. The script (reactions.js, 1.3 KB gzip) loads only on pages with reactions.

#[action(permission = "answers.react")]
pub async fn react_to_answer(press: ReactionPress, db: Db, ctx: Ctx) -> Result<ReactionState> {
    db.toggle_reaction(&ctx, &press.target, &press.reaction, press.pressed).await?;
    db.reaction_state(&ctx, &press.target).await
}

Reactions::of(react_to_answer, format!("answer-{}", answer.id))
    .reaction("helpful", t!("answers.helpful"), "\u{1f44d}")
    .reaction("thanks", t!("answers.thanks"), "\u{1f64f}")
    .state(answer.reactions.clone())

RelativeTime

A moment shown as relative time, such as "3 minutes ago" or "in 2 days", in the page's language. The server writes the exact date and time in the user's time zone inside <time datetime="...">, so the HTML stays the same from minute to minute and a cached page is still right. The runtime rewrites the text with Intl.RelativeTimeFormat when the page loads, when nav.js swaps in a screen and once a minute; the exact time stays in the tooltip. Without script the exact time is the text.

RelativeTime::new(post.published)

Share

A button that shares a link: the system share sheet where the browser has the Web Share API (most phones), and otherwise a copy to the clipboard with a toast that says so. It shares the page being shown, or a screen with Share::screen(screen, params), whose params are checked by the compiler. .title sets the title the share sheet shows, and the label is "Share" in the user's language unless .label gives one. The server renders the button hidden and its script shows it, so a page without script has no button that does nothing. The script (share.js, under 1 KB) loads only on pages that hold a share button.

Share::page().title(post.title.clone())
Share::screen(article, ArticleParams { id: post.id }).label(t!("blog.share_article"))

AlphaIndex

A bar of letters, each a link to a screen with the letter in its params, such as people whose names start with it. The letters come from the user's language in its own order (zeyn::ui::alphabet): Turkish has Ç Ğ I İ Ö Ş Ü in place, Spanish has Ñ, Russian and Arabic have their own alphabets, and every other language gets A to Z. The letter of the screen being shown is marked with aria-current="page" (a Turkish i marks İ), and .other() adds a last # link for names that start with anything else. The links open without a page reload and wrap onto more lines on phones.

#[screen(route = "/people/{letter}", public)]
pub fn people_by_letter(p: LetterParams) -> Screen {
    Screen::new(t!("people.title"))
        .body(AlphaIndex::new(people_by_letter, |l| LetterParams { letter: l.to_string() }).current(&p.letter).other())
}

WeekSchedule

Seven day columns of rows from an action, such as classes, shifts or events of a week. The action is the same kind a DataGrid uses and is checked the same way. .day maps a row to its day: a Date, an Option<Date> (rows without one are left out) or a weekday number from 1 (Monday) to 7 (Sunday); anything else does not compile. Each row is shown as a ListItem, so it can link to a screen. The week starts on the user's first weekday (Monday in Turkish, Sunday in American English), today is marked, days without rows say so, and on narrow screens the days stack. The server loads up to limit rows (100 unless set, at most 500) in the action's order; give the action the week's rows, for example with a filter on the date.

WeekSchedule::of(this_week_classes)
    .title(t!("classes.this_week"))
    .day(|c: &Class| c.date)
    .item(|c: &Class| {
        ListItem::new(c.name.clone())
            .meta(c.starts.to_string())
            .link(class, ClassParams { id: c.id })
    })

Deliveries

Sunday

  • Dried apricots 130S-0006

Monday

  • Cheese 135S-0007

Tuesday Today

  • Green tea 105S-0001
  • Bulgur 140S-0008

Wednesday

  • Coffee beans 110S-0002
  • Lentils 145S-0009

Thursday

  • Olive oil 115S-0003
  • Black tea 150S-0010

Friday

  • Honey 120S-0004
  • Green tea 155S-0011

Saturday

  • Hazelnuts 125S-0005
  • Coffee beans 160S-0012