Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Layouts

Before you start

Start from an application with a few named routes, such as the quick start after URL state and navigation. This guide adds a navigation bar to every page and a header to one group of pages.

Scalive has two kinds of layout:

  • A LiveRootLayout renders the HTML document: <html>, <head>, scripts, stylesheets, and <body>. Every page has exactly one.

  • A LiveLayout wraps the page inside the document, for example in a navigation bar. A page can have several.

Both are classes that extend a trait and implement one view method.

Render the document

The quick start's root layout renders the document for every page:

Source
scala
package quickstart

import scalive.*

final class RootLayout(clientAssets: LiveViewClientAssets, assets: StaticAssets)
    extends LiveRootLayout[Any, Any]:
  def view[Msg](
    content: HtmlElement[Msg],
    pageTitle: Option[String],
    ctx: LiveRootLayoutContext[Any, Any]
  ): HtmlElement[Msg] =
    htmlRootTag(
      lang := "en",
      headTag(
        metaTag(charset  := "utf-8"),
        metaTag(nameAttr := "viewport", contentAttr := "width=device-width, initial-scale=1"),
        liveTitle(pageTitle, default = "Scalive quick start"),
        clientAssets.phoenixScript,
        clientAssets.liveViewScript,
        assets.trackedScript("app.js", defer := true, typ := "text/javascript")
      ),
      bodyTag(content)
    )

The class takes the loaded assets in its constructor, so it can render their versioned URLs. Its two type parameters are the route parameter type and the mount context type the layout reads. A layout shared by every route reads neither, so both are Any.

view is generic in Msg because the same layout wraps pages with different message types. content is the page, and the layout places it in the body. pageTitle is the page's pageTitle at the first render. Pass it to liveTitle so later title changes reach the browser; the default applies when the page has no title.

Scalive calls a root layout's view only for the first HTTP response. Live updates patch the page inside the document and never render the document again, so ctx holds plain values: the route parameters, the request, the URL, and the mount context.

Install the root layout on the router with withRootLayout, as the quick start's Main does.

Wrap every page in shared navigation

An ordinary layout renders the markup that pages share inside <body>. This one adds a navigation bar and marks the link to the current section:

Source
scala
package layouts

import scalive.*

final class AppLayout extends LiveLayout[Any, Any]:
  def view[Msg](content: HtmlElement[Msg], ctx: LiveLayoutContext[Any, Any]): HtmlElement[Msg] =
    val path = ctx.currentUrl.map(_.path.encode)
    div(
      headerTag(
        navTag(
          aria.label := "Main",
          navLink(Routes.home.location, "Home", path.map(_ == "/")),
          navLink(Routes.projects.location, "Projects", path.map(_.startsWith("/projects")))
        )
      ),
      mainTag(content)
    )

  private def navLink[Msg](
    location: LiveLocation,
    label: String,
    current: Signal[Boolean]
  ): HtmlElement[Msg] =
    link.pushNavigate(
      location,
      aria.current.optional(current.map(Option.when(_)("page"))),
      label
    )

Install it on the router with withLayout, next to the root layout:

Source
scala
def apply(clientAssets: LiveViewClientAssets, assets: StaticAssets): LiveApplication[Any] =
  Live.router
    .withRootLayout(RootLayout(clientAssets, assets))
    .withLayout(AppLayout())(
      Routes.home     -> HomeLiveView(),
      Routes.projects -> ProjectsLiveView(),
      Routes.project.withLayout(ProjectLayout()).params -> ProjectLiveView()
    )

Scalive calls an ordinary layout's view once per page lifecycle, like a LiveView's view. Its ctx.params, ctx.request, and ctx.currentUrl are signals, so the layout follows live navigation and patches without being rebuilt. Here aria-current moves to Projects when the visitor opens a project, while the navigation bar itself stays in place.

A layout cannot send messages of its own: its view must accept content of any message type, so it has no message type to send. Use links, as above, nested LiveViews, or browser hooks for interactive chrome.

Give one route its own layout

Install a layout on a route builder to wrap only that route. The registration above adds a header to each project page:

Source
scala
Routes.project.withLayout(ProjectLayout()).params -> ProjectLiveView()

A route layout can read the route's decoded parameters. ProjectLayout declares String, the type of the project path segment, so ctx.params is a Signal[String]:

Source
scala
package layouts

import scalive.*

final class ProjectLayout extends LiveLayout[String, Any]:
  def view[Msg](content: HtmlElement[Msg], ctx: LiveLayoutContext[String, Any]): HtmlElement[Msg] =
    sectionTag(
      p(link.pushNavigate(Routes.projects.location, "All projects")),
      h1(ctx.params.map(project => s"Project $project")),
      content
    )

The compiler checks that a route layout's parameter type matches the route. Install route layouts before .params or .query. When a route prefix with a layout is extended with /, the layout keeps receiving the parameters of the prefix it was installed on.

Know where each layout applies

Layouts can be installed at three levels:

LevelInstall withWraps
RouterLive.router.withRootLayout(root).withLayout(layout)Every route
Named live sessionLive.session(name).withRootLayout(root).withLayout(layout)The session's routes
Routeroute.withRootLayout(root).withLayout(layout)One route

Ordinary layouts nest: router layouts are outermost, then session layouts, then route layouts. Within one level, they nest in installation order. Root layouts do not nest: a route's root layout replaces its session's, which replaces the router's. The router accepts one ordinary layout; sessions and routes accept several. Live sessions and mount aspects shows how to group routes in a named live session.

Decide when pages share a document

Live navigation replaces the page but keeps the document, including its <head>, scripts, and <body> attributes. That is only correct when the destination would render the same document. Scalive compares the two root layouts' key values. When they differ, the browser loads the destination with a full page request instead.

The default key is the root layout's class name. Routes that use the same root layout class therefore share the document, and routes with different classes do not. Override key when one class renders documents that cannot replace each other. This root layout renders a different lang per context, so it includes the language in its key:

Source
scala
object DocumentRoot extends LiveRootLayout[Any, DocumentContext]:
  override def key(ctx: LiveRootLayoutContext[Any, DocumentContext]): String =
    s"document:${ctx.context.language}"

  def view[Msg](
    content: HtmlElement[Msg],
    pageTitle: Option[String],
    ctx: LiveRootLayoutContext[Any, DocumentContext]
  ): HtmlElement[Msg] =
    htmlRootTag(
      lang := ctx.context.language,
      headTag(titleTag(pageTitle.getOrElse("Scalive"))),
      bodyTag(
        dataAttr("params")      := ctx.params.toString,
        dataAttr("request-url") := ctx.request.url.encode,
        dataAttr("current-url") := ctx.currentUrl.encode,
        content
      )
    )

Navigating from a French page to a German page then reloads the document, instead of keeping <html lang="fr">. Equal keys do not bypass named live session boundaries: navigation between sessions always loads a new document.

Reuse layouts across contexts

A layout installed on a named live session or behind a mount aspect can read the typed context they produce, such as the signed-in user. Declare the context type the layout needs as its second type parameter.

A shared layout should depend on a small context, not on a session's full context. Use forContext to adapt it where it is installed:

Fragment
scala
// DocumentRoot reads DocumentContext; AccountContext contains .document.
val accountRoot = DocumentRoot.forContext[AccountContext](_.document)

When the selection is local to installation, pass the selector as a second argument instead:

Fragment
scala
Live.session("account")
  .withMountAspect(accountContext)
  .withRootLayout(DocumentRoot, _.document)
  .withLayout(DocumentLayout, _.document)

The builder supplies the selector's input type. These two-argument overloads exist on named live session builders, including admitted ones, and on route builders after a mount aspect, before .params or .query. The router accepts only layouts whose context type is Any; install a context-dependent root layout on a session or route.

Selection uses the context available where the layout is installed. Appending another mount aspect afterward does not make the layout read the final accumulated context. Only the layout's context value is adapted; parameters, request, URL, content, and page title are unchanged. Selectors must be pure and deterministic: a root layout applies its selector separately for key and view. The identity root, the default when no root layout is installed, stays unchanged and does not evaluate the selector.

This compiled example shares one document across public and account contexts, keeps a layout through a later aspect, and overrides the root for one route. Its fixed context aspects demonstrate composition, not authentication:

Source
scala
object LayoutContextExample:
  final case class DocumentContext(language: String)
  final case class PublicContext(document: DocumentContext)
  final case class AccountContext(document: DocumentContext)

  final class EventlessPage(content: String, title: String) extends LiveView.Eventless[String]:
    def mount(ctx: MountContext)                          = ZIO.succeed(content)
    override def pageTitle(model: String): Option[String] = Some(title)
    def view(model: Signal[String])                       = div(idAttr := "page", model)

  object DocumentLayout extends LiveLayout[Any, DocumentContext]:
    def view[Msg](
      content: HtmlElement[Msg],
      ctx: LiveLayoutContext[Any, DocumentContext]
    ): HtmlElement[Msg] =
      sectionTag(
        idAttr                  := s"document-${ctx.context.language}",
        dataAttr("params")      := ctx.params.map(_.toString),
        dataAttr("request-url") := ctx.request.map(_.url.encode),
        dataAttr("current-url") := ctx.currentUrl.map(_.encode),
        content
      )

  object DocumentRoot extends LiveRootLayout[Any, DocumentContext]:
    override def key(ctx: LiveRootLayoutContext[Any, DocumentContext]): String =
      s"document:${ctx.context.language}"

    def view[Msg](
      content: HtmlElement[Msg],
      pageTitle: Option[String],
      ctx: LiveRootLayoutContext[Any, DocumentContext]
    ): HtmlElement[Msg] =
      htmlRootTag(
        lang := ctx.context.language,
        headTag(titleTag(pageTitle.getOrElse("Scalive"))),
        bodyTag(
          dataAttr("params")      := ctx.params.toString,
          dataAttr("request-url") := ctx.request.url.encode,
          dataAttr("current-url") := ctx.currentUrl.encode,
          content
        )
      )

  object ApplicationLayout extends LiveLayout[Any, Any]:
    def view[Msg](content: HtmlElement[Msg], ctx: LiveLayoutContext[Any, Any]): HtmlElement[Msg] =
      mainTag(idAttr := "application", content)

  object ApplicationRoot extends LiveRootLayout[Any, Any]:
    def view[Msg](
      content: HtmlElement[Msg],
      pageTitle: Option[String],
      ctx: LiveRootLayoutContext[Any, Any]
    ): HtmlElement[Msg] =
      htmlRootTag(
        lang := "en",
        headTag(titleTag(pageTitle.getOrElse("Scalive"))),
        bodyTag(content)
      )

  val publicContext = LiveSessionMountAspect.fromRequest[Any, String, PublicContext](
    _ => ZIO.succeed("fr" -> PublicContext(DocumentContext("fr"))),
    (language, _) => ZIO.succeed(PublicContext(DocumentContext(language)))
  )
  val accountContext = LiveSessionMountAspect.fromRequest[Any, String, AccountContext](
    _ => ZIO.succeed("de" -> AccountContext(DocumentContext("de"))),
    (language, _) => ZIO.succeed(AccountContext(DocumentContext(language)))
  )
  val accountAudit = LiveSessionMountAspect.make[Any, AccountContext, String, String](
    (_, _) => ZIO.succeed("audited" -> "audited"),
    (claim, _, _) => ZIO.succeed(claim)
  )
  val accountVariant =
    LiveRouteMountAspect.make[Any, Unit, (AccountContext, String), DocumentContext]((_, _) =>
      ZIO.succeed(DocumentContext("de-CH"))
    )

  val defaultRoute = scalive.live / "default" -> EventlessPage("default", "Default title")
  val publicRoute  = (scalive.live / "public").context { (context: PublicContext) =>
    EventlessPage(s"public-${context.document.language}", "Public title")
  }
  val publicSession = scalive.Live
    .session("public")
    .withMountAspect(publicContext)
    .withLayout(DocumentLayout.forContext[PublicContext](_.document))
    .withRootLayout(DocumentRoot.forContext[PublicContext](_.document))(publicRoute)

  val accountBuilder = scalive.Live
    .session("account")
    .withMountAspect(accountContext)
    .withLayout(DocumentLayout, _.document)
    .withRootLayout(DocumentRoot, _.document)
    .withMountAspect(accountAudit)
  val accountRoute = (scalive.live / "account").context { (context: (AccountContext, String)) =>
    EventlessPage(s"account-${context._1.document.language}-${context._2}", "Account title")
  }
  val accountOverride = (scalive.live / "account" / "variant")
    .withMountAspect(accountVariant)
    .withLayout(DocumentLayout, _._2)
    .withRootLayout(DocumentRoot, _._2)(EventlessPage("account-variant", "Variant title"))

  val application = scalive.Live.router
    .withLayout(ApplicationLayout)
    .withRootLayout(ApplicationRoot)(
      defaultRoute,
      publicSession,
      accountBuilder(accountRoute, accountOverride)
    )
end LayoutContextExample