Lucky Logo

# Intro to Lucky HTML

Lucky uses Crystal methods for rendering HTML. The Crystal methods map as closely as possible to how HTML is used. To help with the transition we also have a small app for converting HTML to Lucky methods.

Using Lucky HTML adds an additional layer of type-safety, is auto-formatted with Crystal’s formatter, and can be much easier to refactor with regular Crystal methods as HTML gets larger.

If you still don’t want to use Lucky HTML see rendering templates to learn how to use Lucky pages with templates files.

# Rendering a page

Let’s say we have an action and we want to render all of our user’s names:

# in src/actions/users/
class Users::Index < BrowserAction
  get "/users" do
    # Renders the Users::IndexPage
    html IndexPage, user_names: ["Paul", "Sally", "Jane"]

# Creating a page

Let’s create the page for our Users::Index action. You can generate a file quickly with lucky Users::IndexPage, then modify it:

# in src/pages/users/
class Users::IndexPage < MainLayout
  needs user_names : Array(String)

  def content
    ul class: "my-user-list" do
      user_names.each do |name|
        li name, class: "user-name"

Woo hoo! We created our first page. Let’s walk through what’s going on.

# Declaring what a page needs

You’ll notice we used needs near the top of the class. This declares that for this page to render we need an Array of Strings and that they will be accessible from the user_names getter method. We set the user names by passing it in the html macro in our action:

# src/actions/users/
class Users::Index < BrowserAction
  get "/users" do
    html IndexPage, user_names: ["Paul", "Sally", "Jane"]

This is nice because you won’t accidentally forget to pass something to a page ever again. If you forget, the compiler will tell you that you’re missing something.

# Default values and nilable needs

Your page or component may need some value that is optional. In this case, you can assign a default value to your needs or just make the type nilable. (i.e. String?)

class Users::IndexPage < MainLayout
  needs page : Int32 = 1
  needs status : String?

  def content
    # `page` will always have a value
    # `status` may be nil

From your action, you can optionally pass either of these values.

get "/users" do
  html IndexPage
  # or
  html IndexPage, page: 2
  # or
  html IndexPage, status: "active", page: 3

# Using needs with Bool

When you use a Bool value for needs, Lucky will generate a helpful method for you that ends in ? to denote that it will return true or false.

class Users::IndexPage < MainLayout
  needs admin : Bool = false

  def content
    if admin?
      # ...
      # ...

# Rendering HTML in our page

We then wrote a content method in our class to render our HTML. The content will be rendered in your MainLayout (defined in src/pages/ Tags are generated with regular Crystal methods. Most all HTML5 tags are available by default.

Note that paragraph tags are para instead of p since p is already used by Crystal. You can use pp to debug output.

# Examples of using HTML tags

# Generate a ul tag with no other options (class, data attributes, etc), and render tags within it
ul do
  li "Hey!"

# Generate a ul tag with options and more tags within it
ul class: "my-list", data_foo: "bar" do
  li "Excellent list item"

# Generate a tag with the text as it's content
h1 "My cool test"

# Generate a tag with the text as content and with options
h1 "My cool test", class: "app-header"

Order and nesting works about the same as how you would write normal HTML.

def content
  # You can have a list
  ul do
    li "List item"

  # And underneath it render something else
  footer "Copyright Notice"

# Creating custom HTML tags

If you need to create a non-standard HTML tag for your application, you can use the tag method.

def content
  # Renders <my-custom-tag class="special control">Special</my-custom-tag>
  tag("my-custom-tag", class: "special control") do
    text "Special"

# Examples of HTML attributes

All of the HTML tag methods allow for passing in any HTML attribute.

def content
  div(id: "someID", class: "row highlight special") do
    span "A special code", class: "text-note"

If you need to pass in data attributes, or any arbitrary attributes for use in SPAs (i.e. ng-app, ng-click, etc…), you can also use a string.

def content
  div(ng_model: "something", data_action: "someAction", "ng-click": "update")

In some cases, you find that you want to use boolean attributes for forms or working with SPAs. For these, you just pass an Array(Symbol) to the attrs option for the tag.

def content
  # Renders <div id="application" ng-app></div>
  div(id: "application", attrs: [:ng_app])

  # Renders <button disabled>Click</button>
  button("Click", attrs: [:disabled])

NOTE: Lucky will automatically run attributes through a dasherize inflector. This means underscores will become a dash once rendered. (e.g. :ng_app becomes ng-app). In more complex cases like you see in Vuejs, crystal allows you to use quotes like in :"v-on:click"

There are a few specials helpers that make it easier. For creating links with an anchor tag, we have the link helper.

NOTE: If you are looking for a way to create <link> tags in the <head>, use the empty_tag helper.

link "Show user", to: Users::Show.with(, class: "some-html-class"

# Leave off `with` if an route doesn't need params
link "List of users", to: Users::Index

When you pass a route helper as we did with Users::Show.with(, the link helper automatically sets the path and the correct HTTP verb.

Since the HTTP verb (GET, POST, PUT, etc.) is automatically used by link we can do delete links like this:

# data-method="delete" will automatically be set.
# This means the link submits with the right HTTP verb automatically.
link "Delete", to: Users::Delete.with(

# You can use the same nesting as with most other tags
link to: Users::Delete.with(, class: "delete-link" do
  img src: asset("images/delete-icon.svg")

Since the link automatically includes the HTTP verb, you are guaranteed at compile time that the link will go to the right place.

Crystal will search for constants (classes and modules) in the current namespace first. This usually works fine, but there are times when Crystal may not know how to find the correct constant. In this case you can prefix the constant with :: to look at the root namespace. This is best explained with an example.

Let’s say we have a nested action and a non-nested action like this:

# Tasks nested in Projects namespace
class Projects::Tasks::Index < BrowserAction
  # call the action

# Tasks is not nested
class Tasks::Show < BrowserAction
  # call the action

Crystal will incorrectly look for Tasks::Show in Projects::Tasks:

class Projects::Tasks::IndexPage < MainLayout
  def content
    # Crystal will look for Projects::Tasks::Show since it is called within
    # Projects::Tasks. Crystal will see the Tasks constant and assume that's
    # what we want
    link "View task", Tasks::Show.with(123)

To fix this, prefix the call constant with ::

# This tells Crystal to look for the top-level Tasks constant
link "View task", ::Tasks::Show.with(123)

The link helper method doesn’t allow for a plain string path. If you need to pass a string, you can use the a() method. (e.g. a href: "/").

# Rendering HTML forms

Lucky gives you lots of helper methods to make working with forms easier. See the rendering HTML forms guide to learn more.

For info on interacting with databases, see the saving data with operations guide.

# Empty tag

If there’s a bodyless tag you would like to render, but there is no helper for it, then use empty_tag.

# Renders an alternative language link element:
# <link rel="alternate" hreflang="es" href="" />

empty_tag "link", rel: "alternate", hreflang: "es" href: ""

The first argument is a string that represents the tag name, the second is a hash passed to render as attributes. This is especially convenient for elements with varying attributes, like a set of favicons:

head do
  # ...

  empty_tag "link", rel: "apple-touch-icon", sizes: "180x180", href: "/apple-touch-icon.png"
  empty_tag "link", rel: "icon", type: "image/png", sizes: "32x32", href: "/favicon-32x32.png"
  empty_tag "link", rel: "icon", type: "image/png", sizes: "16x16", href: "/favicon-16x16.png"
  empty_tag "link", rel: "manifest", href: "/site.webmanifest"
  empty_tag "link", rel: "mask-icon", href: "/safari-pinned-tab.svg", color: "#c0ffee"

# Other special helpers

  • html_doctype - Renders <!DOCTYPE html>
  • css_link(href, **options) - Renders a <link rel="stylesheet" media="screen"> tag with href and any additional/override options
  • js_link(src, **options) - Renders a <script> tag with src and any additional/override options
  • utf8_charset - Renders a <meta charset="utf8"> tag
  • responsive_meta_tag - Another meta tag for responsive design.
  • canonical_link(href) - Renders a <link rel="canonical" href="..."> tag.
  • nbsp(how_many = 1) - Renders &nbsp; entity for the number of times in how_many (1 by default).
  • raw - Render RAW string to the page.

Note: Using raw can be dangerous and should never be used with unescaped user-generated data.

# Rendering text

Sometimes you want to render plain text. To do that use the text method.

Strings rendered with text are automatically HTML escaped for security. Text passed to tags is also escaped.

div "email" do
  text "This is the email text"
  span "inbox", class: "email-tag"

# Render unescaped (raw) text

div "email" do
  # Use the `raw` method to render unescaped text
  raw "&middot;" # Render a middot HTML entity

# Inlining SVG files

Lucky allows you to inline SVG files at compile time using the inline_svg macro in pages and components:

link to: Home::Index do

The full path of the icon resolves to src/svgs/menu/home.svg. The svgs directory can be configured, as described further down in this section.

By default, this macro will strip the XML declaration, comments, unnecessary whitespace and all attributes related to styling.

The stripped attributes are class, fill, stroke, stroke-width and style. This can also be configured.

Inlined SVGs can then be styled with CSS:

[data-inline-svg] {
  fill: none;
  stroke: '#666';
  stroke-width: 1.5px;

Or with more specificity:

[data-inline-svg="menu/home.svg"] {
  fill: pink;

In some cases, SVG may need to keep their styling attributes, like logos, for example. By passing false as the second argument, the styling attributes will remain in place:

inline_svg("menu/logo.svg", false)

Since SVGs with their original styling most likely don’t require additional CSS styling, they have a different selector: [data-inline-svg-styled] or [data-inline-svg-styled="menu/logo.svg"].

# Configuration

Because SVGs are processed and inlined at compile time, configuration happens through annotations. Those settings can be overridden by creating an initializer:

# config/
module Lucky::SvgInliner

The example above will tell the compiler to look for SVGs in src/icons rather than in the default src/svgs.

Another configuration option is the regular expression to strip styling attributes. Here’s an example with a regex that will only strip the style and class attributes:

@[Lucky::SvgInliner::StripRegex(/(class|style)="[^"]+" ?/)]
module Lucky::SvgInliner

# Finding the current page

Lucky provides the convenient current_page? helper on both pages and components to make it easier to customize content based on context.

# Basic usage

current_page? accepts a RouteHelper, Action, or path String.

One common use case is to highlight the currently-viewed page in a navigation header:

nav do
  ul do
    li data_current: current_page?(Home::Index) do
      link "Home", to: Home::Index

    li data_current: current_page?(Dashboard::Index) do
      link "Your dashboard", to: Dashboard::Index

    li data_current: current_page?(Me::Show) do
      link "Your profile", to: Me::Show

# Advanced usage

Let’s take a look at some of the additional features we can take advantage of with current_page?.

For example, if we are visiting

# => true

# => true

# => true

# => true

# => true

We can provide an optional second argument to current_page?, check_query_params, to tell Lucky whether or not it should care about parameters.

Let’s take a look at our example from before with this new parameter in mind:

current_page?(Users::Index, check_query_params: true)
# => true

current_page?("/users", check_query_params: true )
# => true

current_page?("/users?sort_by=email", check_query_params: true)
# => false

current_page?("/users?sort_by=name", check_query_params: true)
# => true

current_page?("", check_query_params: true)
# => true

# Previous URL

Lucky also has a previous_url helper method you can use on your pages.

It returns the url of the page that issued the request (the referrer) if possible, otherwise it uses the provided default fallback location.

The referrer information is pulled from the ‘Referer’ header on the request. This is an optional header, and if the request is missing this header the fallback will be used.

Ex. within a Lucky Page, previous_url can be used to provide an href to an anchor element that would allow the user to go back.

a "Back", href: previous_url(fallback: Users::Index)

# Formatting and Page helpers

Formatting text on pages is pretty common. Lucky gives you several handy methods to help formatting.

Some page helpers return a String, and some page helpers will write directly to the page.

# Converting a number to currency format

Returns a String formatted to a currency format.

# Returns standard U.S. format
text number_to_currency(1234.43)
# => $1234.43

# Additional options supported for other formats
text number_to_currency(1234.32, unit: "€", separator: ",", delimiter: ".")
# => €1.234,32

# Truncating text

Returns a String truncated to length.

text truncate_text("some really long text here", length: 12)
# => some real...

# Truncating HTML

Truncates the text, and writes HTML directly to the page.

truncate("Four score and seven years ago", length: 20) do
  link "Read more", to: President::Addresses
# => "Four score and se...<a href="#">Read more</a>"

# Highlighting text

This takes phrases in the form of Array(String | Regex) and wraps the matching phrases in a <mark></mark> tag.

highlight("From this taco meat we shall eat for days!", phrases: ["taco", /eat/])
# => From this <mark>taco</mark> m<mark>eat</mark> we shall <mark>eat</mark> for days!

# Pluralizing a word

Returns a String using the first arg to determine how to pluralize the second arg.

text "I have #{pluralize(2, "shoe")}"
# => I have 2 shoes

# Wrapping words

Returns a String. Adds new lines (\n) after the nearest word limited to line_width.

word_wrap("Maybe some code would go here", line_width: 6)
# => Maybe \nsome \ncode \nwould \ngo \nhere"

# Change the new line character with `break_sequence`.
word_wrap("Maybe some code would go here", line_width: 6, break_sequence: "<br>")
# => Maybe <br>some <br>code <br>would <br>go <br>here"

# Simple text format

Formats the text with some simple HTML and writes directly to the page.

# => <p>Nice<br>easy<br>format!</p>

# Sentence lists

Returns a String, and creates a comma-separated sentence from the provided Enumerable list.

text to_sentence(["Tacos", "Burritos", "Salsa"])
# => Tacos, Burritos, and Salsa
text to_sentence(words, last_word_connector: " and ")
# => Tacos, Burritos and Salsa

By default to_sentence will include a serial comma. Override that with the last_word_connector option.

# Excerpt from a paragraph

Returns a String. Similar to truncate_text, but for the middle of a large body of text.

text excerpt("This is a beautiful morning", "beautiful", radius: 5)
# => " a beautiful morn..."

# Distance of time in words

Returns a String with distance in time between two Time objects.

distance_of_time_in_words(Time.utc(2019, 8, 14, 10, 0, 0), Time.utc(2019, 8, 14, 10, 0, 5))
# => "5 seconds"
distance_of_time_in_words(Time.utc(2019, 8, 14, 10, 0), Time.utc(2019, 8, 14, 10, 25))
# => "25 minutes"
distance_of_time_in_words(Time.utc(2019, 8, 14, 10), Time.utc(2019, 8, 14, 11))
# => "an hour"
distance_of_time_in_words(Time.utc(2019, 8, 14), Time.utc(2019, 8, 16))
# => "2 days"
distance_of_time_in_words(Time.utc(2019, 8, 14), Time.utc(2019, 10, 4))
# => "about a month"
distance_of_time_in_words(Time.utc(2019, 8, 14), Time.utc(2061, 10, 4))
# => "almost 42 years"

# Time ago in words

Similar to distance_of_time_in_words. Returns a String with distance in time between current moment and some time in the past.

time_ago_in_words(Time.utc(2019, 8, 30))
# => "about a month"

# Cycle values

The most common case is alternating an HTML class name between rows of data. Lucky comes with a cycle method that makes this much easier to do.

posts.each do |post|
  tr class: cycle(["bg-gray-600", ""]) do
    td post.title

In this example, the first row, and all odd rows, will be <tr class="bg-gray-600">, but the next row, and all even rows, will be <tr class="">. You can pass as many values as you’d like to cycle through on each iteration.

# Layouts

Pages have layouts that make it easier to share common elements.

# Layouts in a default Lucky application

  • MainLayout - The layout used when a user is signed in
  • AuthLayout - The layout used when the user is not signed in

Pages inherit from MainLayout, AuthLayout or another layout you decide to create. Layouts can declare abstract methods or use responds_to? for more advanced page layouts.

# Rendering page specific content in the layout

Let’s start with a quick example. In newly generated Lucky projects you’ll see that MainLayout defines a page_title method that is passed to the Shared::LayoutHeader component. Any page that inherits from MainLayout can write a new page_title method to override the one in MainLayout.

So to set a page specific title, do this:

# src/pages/users/
class Users::IndexPage < MainLayout
  # Override the page_title method that was originally
  # defined in MainLayout
  def page_title
    "List of users"

This technique can be used to render other types of content, like a sidebar (similar to content_for in Rails):

# src/pages/ in the `render` method
def render
  # Other layout code left out for brevity
  div class: "sidebar" do
    # Now pages can render content in a sidebar

# This makes it so that every page needs to implement the sidebar method
abstract def render_sidebar

Then in a page that inherits from MainLayout:

class Users::IndexPage < MainLayout
  def content
    text "Rendering something in the main part of the layout"

  def render_sidebar
    text "This is content for the sidebar"

# Using render_if_defined in layouts for optional content

Sometimes pages have almost the same layout, but have just one or two parts that are optional. You can handle this with responds_to? and render_if_defined.

responds_to? should be used when pages mostly look the same, but have just minor changes to the layout that happen on some pages. If pages look significantly different, consider extracting a new layout class instead.

# Put this in a layout's `render` method
# This makes it so that pages can have an optional `help_message`.
if responds_to?(:help_section)
  div class: "help-section" do
    render_if_defined :help_section

# If a page has a help section, add a `help_section` method
class Admin::Users::IndexPage < MainLayout
  def help_section
    para "Click the 'export' button to export a CSV of all users"

If you have shared HTML between multiple layouts (like a footer or navigation section) you can extract components to use across layouts. See “Creating and using components”

# Extracting methods for code clarity

Extracting code for reuse or clarity is easy since pages are made of classes and methods.

class Users::ShowPage < MainLayout
  def content

  # We can extract a method to make our code easier to understand
  private def render_user_header
    div class: "user-header" do
      h1 "Users"
      link "Back to users index", to: Users::Index

# Creating and using components

The most powerful and flexible way to share code is to use a Component. Components are Crystal classes that declare what objects they need, and then render HTML.

Let’s generate one with the command lucky gen.component Users::Row and fill render:

# in src/components/users/
class Users::Row < BaseComponent
  needs user : User

  def render
    div class: "user-row" do
      link, to: Users::Show.with(user)

Now we can mount the component in the page:

class Users::IndexPage < MainLayout
  needs user : User

  def content
    mount Users::Row, user: user

You can also mount components from within other components in the same way as in pages.

# Yielding a block function to a component

You can also render a block in a component. This is helpful for when you want have custom content injected into the component

lucky gen.component RoundedContainer:

class RoundedContainer < BaseComponent
  def render
    div class: "rounded-container" do

Now use it in a page:

mount RoundedContainer do
  h1 "This will be inside the div defined in the component"

# Sharing data used by all pages

Let’s say you want to show the currently signed in user on every page in a layout. It would be a pain to have to type needs current_user : User in every page and expose current_user: find_the_user in every action. Lucky’s got you covered.

# Example using needs and expose

You can add needs to the MainLayout if every page that uses that layout needs something.

For actions, we have an expose macro that makes it easy to automatically pass data to rendered pages.

The expose macro sends the results of a method to the page, as if you had passed it manually:

# Without `expose`
class Users::Index < BrowserAction
  get "/users" do
    html IndexPage, current_user_name: current_user_name

  private def current_user_name

# The equivalent version using `expose`
class Users::Index < BrowserAction
  expose current_user_name

  get "/users" do
    html IndexPage

  private def current_user_name

# Accessing it on all pages with `needs`
abstract class MainLayout
  include Lucky::HTMLPage

  needs current_user_name : String


class Users::IndexPage < MainLayout
  def content
    h1 "Hello, #{current_user_name}"

needs will create a Crystal getter method by that name for you. Putting it in the MainLayout gives you access to that method on all pages.

# Full example

The best way to learn about expose and needs is to look at the default generated actions in src/actions such as the BrowserAction in src/actions/

Then take a look at the layouts in src/pages to see how they use needs.

It is also helpful to look at the action mixins in src/actions/mixins these declare the exposures and pipes for authentication.

# Rendering HTML with templates (ECR, Slang, etc.)

If you are writing content heavy HTML, or just prefer templating languages here is what you can do.

We still recommend using Lucky HTML methods if possible. If you’re having trouble getting started, try the HTML to Lucky converter

# Install Kilt

Add Kilt to your shard.yml

    github: jeromegn/kilt

# Add Kilt to shards file

Add this line to src/

require "kilt"

# Add a render_template macro to make it easier to render

Extend Lucky HTML rendering with a render_template macro:

# Place this in src/
module Lucky::HTMLBuilder
  macro render_template(template)
    Kilt.embed "src/pages/{{}}", io_name: view

Then require it in src/

# In src/
# Add this to the top of the list of 'require' statements
require "./render_template"

# Use it in your HTML pages

You still use a Page object which means you can use all the link, form, and other helpers Lucky provides. Here is an example of how you’d rewrite a page to use an template.

Here’s what a typical Lucky page would look like:

# In src/posts/
class Posts::NewPage < MainLayout
  needs save_post : SavePost

  def content
    h1 "New Blog Post"

  def render_post_form(operation)
    form_for Posts::Create do
      mount Shared::Field, operation.title, &.text_input(autofocus: "true")
      mount Shared::Field, operation.body
      mount Shared::Field, operation.published_at

      submit "Save", data_disable_with: "Saving..."

To render the HTML with a template, use render_template in the content method:

def content
  render_template "posts/new.html.ecr"

Then create the template in src/posts/new.html.ecr:

<h1>New Blog Post<h1>
<% render_post_form(@operation) %>

And you’re done!

# Gotchas

You’ll note that we used <% %> and not <%= %> when rendering the form in the example above. This is because Lucky helpers like link and form_for write directly to the page. If you use <%= %> it will render the content twice. If you see content appear twice, use <% %> (no =).

However if you just want to output a static value you would use <%= %>:

My name is: <%= %>

# Caching HTML Content

Lucky applications can leverage lucky_cache to cache HTML content within pages. By caching parts of your application, Lucky can serve already rendered and processed pages resulting in faster page loads for your application.

To begin utilizing lucky_cache, add it as a dependency to your shards.yml file.

    github: luckyframework/lucky_cache

Then run shards install to update your dependencies.

Next, you’ll need to require lucky_cache in your application. Create a config file in config/ where you can configure the cache options.

# config/

require "lucky_cache"

LuckyCache.configure do |settings| =
  settings.default_duration = 5.minutes

With lucky_cache now included in your Lucky application, you have access to the LuckyCache::HtmlHelpers module that can be included in your pages. This module exposes a cache method to be used to cache your HTML content.

You’ll need to provide a unique key to the cache method that Lucky can use to reference the unique content within the cache. A common pattern is to use any unique identifier, such as model id or page slug, to ensure no conflicts with cached data. The cache also provides an optional second argument that you can use to specify the duration until the cached data expired. If this argument is not provided, lucky_cache will default to the duration configured in the base application configuration.

class Posts::ShowPage < MainLayout
  include LuckyCache::HtmlHelpers
  needs post : Post

  def content
    cache("post:#{}:comments", expires_in: 1.hour) do
      post.comments.each do |comment|
        div comment.text

For more information about lucky_cache, see the API docs.

See a problem? Have an idea for improvement? Edit this page on GitHub