Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Layout & Scrolling

Every container takes its layout as an argument: row and column take a layout, and rowWith/columnWith take a Layout -> Layout function first. Modifiers compose with (.), and when two set the same property, the leftmost wins.

settingsPanel :: NanoUI ()
settingsPanel =
  columnWith (gap 8 . padAll 12 . fillW) $ do
    label "Settings"
    whenM (buttonWith (fixedW 120) "Apply") apply

Logical pixels, origin top-left, y downward. Backends handle DPI scaling.

Each example below expands to its code and the frame it renders. The frames add padAll 14 and fill the window so the spacing shows.

Containers

A container wraps a NanoUI block and arranges the widgets it draws. The plain form (row) uses the inherited layout; the ...With form takes a modifier function.

row / rowWith: children left to right
rowExample =
  rowWith (gap 8) $
    mapM_ (void . button) ["One", "Two", "Three"]
Three buttons in a horizontal row.
column / columnWith: children top to bottom
columnExample =
  columnWith (gap 8) $
    mapM_ (void . buttonWith fillW) ["One", "Two", "Three"]
Three full-width buttons stacked vertically.
grid / gridWith: rows of equal columns
gridExample =
  gridWith 3 (gap 8 . fillW) $
    mapM_
      (\i -> void (buttonWith fillW ("Cell " <> T.pack (show (i :: Int)))))
      [1 .. 6]
Six buttons in a three-column grid.
panel / panelWith: a styled surface
panelExample =
  panelWith (fixedW 300 . padAll 12 . gap 6) $ do
    heading "Deployments"
    muted "3 services running"
A panel with a heading and a muted line of text.
card: a full-width panel with a minimum width
cardExample =
  card $ do
    heading "Build #482"
    muted "Passed in 1m 42s"
A full-width card showing a build result.
callout: a panel tinted by a semantic colour
calloutExample = do
  warnCol <- themeWarning <$> uiTheme
  callout warnCol $ do
    heading "Certificate expiring"
    muted "TLS certificate expires in 3 days."
An amber callout warning about an expiring certificate.
layers: children stacked in one box, later on top
layersExample =
  layers $ do
    let badgeRed = colorRGB 204 64 64
    void (buttonWith (fixedWH 170 40) "Inbox")
    styled (panelStyle (background badgeRed . borderColor badgeRed)) $
      panelWith (alignEnd . alignTop . padXY 6 1) $
        labelWith (fontColor colorWhite) "3"
A button with a small red count badge stacked at its top right.
center: content centred on both axes
centerExample =
  panelWith (fillW . fillH) $
    center (label "Centred on both axes")
A label centred in a large panel.
responsive / responsiveRowCol: one layout when wide, another when narrow

responsive breakpoint wide narrow body picks a container by window width; responsiveRowCol is the common row/column pair.

responsiveExample =
  responsiveRowCol 640 (tight . gap 8 . fillW) $ do
    panelWith (fillW . padAll 12) (label "Sidebar")
    panelWith (fillW . padAll 12) (label "Content")

A 420 px window:

Sidebar and Content stacked vertically at 420 pixels wide.

The same view at 760 px:

Sidebar and Content side by side at 760 pixels wide.
mouseArea: hover and clicks for a whole region
mouseAreaExample = do
  (_, area) <-
    panelWith fillW $
      mouseArea (fillW . padAll 10 . gap 4) $ do
        label "build.log"
        muted "Click anywhere in this card"
  when (respClicked area) (muted "opening build.log")
A card containing two lines, wrapped in a mouse area.
separator: a one-pixel rule
separatorExample =
  columnWith (fillW . gap 8) $ do
    label "Above the rule"
    separator
    label "Below the rule"
A horizontal rule between two labels.
spacer: flexible space between siblings
spacerExample =
  rowWith (fillW . alignMid) $ do
    label "Left"
    spacer (Grow 1) Fit
    label "Right"
Left and Right labels pushed to opposite ends of a row.

Modifiers

Modifiers are Layout -> Layout functions, so they chain with (.). The leftmost wins when two set the same property, so tight . padAll 14 has no padding while padAll 14 . tight keeps it.

Sizing

fillW: take all remaining width
fillWExample =
  rowWith (fillW . gap 8) $ do
    void (buttonWith (fixedW 110) "fixedW 110")
    void (buttonWith fillW "fillW")
A fixed-width button beside a button filling the rest of the row.
fillH: take all remaining height
fillHExample =
  rowWith (fillW . gap 8 . fixedH 96) $ do
    panelWith (fixedW 120 . fillH) (label "fillH")
    panelWith (fixedW 120) (label "natural")
One panel filling a fixed-height row and one panel at its natural height.
grow: fill both axes (fillW . fillH)
growExample =
  rowWith (grow . gap 8) $ do
    panelWith (fixedW 120) (label "fixedW 120")
    panelWith grow (label "grow")
A small fixed panel beside a panel growing to fill the remaining space.
fixedW: an exact width
fixedWExample =
  rowWith (gap 8) $ do
    void (buttonWith (fixedW 60) "60")
    void (buttonWith (fixedW 110) "110")
    void (buttonWith (fixedW 170) "170")
Three buttons at 60, 110, and 170 pixels wide.
fixedH: an exact height
fixedHExample =
  rowWith (gap 8 . fillW) $ do
    void (buttonWith (fixedH 30) "fixedH 30")
    void (buttonWith (fixedH 56) "fixedH 56")
    void (buttonWith (fixedH 82) "fixedH 82")
Three buttons at 30, 56, and 82 pixels tall.
fixedWH: an exact width and height
fixedWHExample =
  rowWith (gap 8) $ do
    void (buttonWith (fixedWH 120 36) "120 x 36")
    void (buttonWith (fixedWH 200 72) "200 x 72")
A small button and a larger button with explicit sizes.
minW: a lower width bound
minWExample =
  rowWith (fillW . gap 8) $ do
    void (buttonWith (minW 200) "minW 200")
    void (button "natural")
A wide minimum-width button beside a natural-width button.
maxW: an upper width bound
maxWExample =
  rowWith (fillW . gap 8) $ do
    void (buttonWith (fillW . maxW 160) "maxW 160")
    void (buttonWith fillW "fillW")
A capped button beside an uncapped fill-width button.
minH: a lower height bound
minHExample =
  rowWith (fillW . gap 8) $ do
    panelWith (minH 80 . fillW) (label "minH 80")
    panelWith fillW (label "natural")
A panel forced to 80 pixels tall beside one at its natural height.
maxH: an upper height bound
maxHExample =
  rowWith (fillW . gap 8 . fixedH 100) $ do
    panelWith (maxH 50 . fillW) (label "maxH 50")
    panelWith (fillW . fillH) (label "fillH")
A panel capped at 50 pixels beside one filling a 100-pixel row.
percent: a percentage of the parent's width
percentExample =
  rowWith (fillW . gap 8) $ do
    panelWith (percent 30) (label "percent 30")
    panelWith (percent 60) (label "percent 60")
Two panels sized to 30 and 60 percent of their row.
aspect: keep a width / height ratio
aspectExample =
  panelWith (fillW . aspect (16 / 9)) $
    center (label "16 : 9")
A wide panel with a 16 by 9 aspect ratio.

Spacing

padAll: equal padding on all four edges
padAllExample =
  panelWith (fixedWH 260 120 . padAll 20) $
    panelWith (fillW . fillH) (label "padAll 20")
A panel inset by 20 pixels on every side inside another panel.
padXY: horizontal, then vertical, padding
padXYExample =
  panelWith (fixedWH 280 100 . padXY 32 10) $
    panelWith (fillW . fillH) (label "padXY 32 10")
A panel with wide horizontal padding and shallow vertical padding.
padLeft: left padding
padLeftExample =
  panelWith (fixedWH 260 100 . padLeft 48) $
    panelWith (fillW . fillH) (label "padLeft 48")
A panel inset 48 pixels from the left.
padRight: right padding
padRightExample =
  panelWith (fixedWH 260 100 . padRight 48) $
    panelWith (fillW . fillH) (label "padRight 48")
A panel inset 48 pixels from the right.
padTop: top padding
padTopExample =
  panelWith (fixedWH 260 120 . padTop 40) $
    panelWith (fillW . fillH) (label "padTop 40")
A panel inset 40 pixels from the top.
padBottom: bottom padding
padBottomExample =
  panelWith (fixedWH 260 120 . padBottom 40) $
    panelWith (fillW . fillH) (label "padBottom 40")
A panel inset 40 pixels from the bottom.
padLRTB: left, right, top, bottom padding
padLRTBExample =
  panelWith (fixedWH 280 120 . padLRTB 40 8 30 12) $
    panelWith (fillW . fillH) (label "padLRTB 40 8 30 12")
A panel with different padding on each side.
gap: space between children
gapExample =
  columnWith (fillW . gap 12) $ do
    rowWith (gap 4) $ mapM_ (void . button) ["gap 4", "B", "C"]
    rowWith (gap 24) $ mapM_ (void . button) ["gap 24", "B", "C"]
Two rows with tight and wide spacing between their buttons.
tight: remove container padding, keep the gap
tightExample =
  rowWith (gap 16 . fillW) $ do
    panelWith (padAll 14 . gap 6 . fillW) $ do
      muted "padAll 14"
      label "default padding"
    panelWith (tight . padAll 14 . gap 6 . fillW) $ do
      muted "tight"
      label "no padding"
Two panels comparing normal padding with tight.

Alignment and wrapping

alignStart, alignCenter, and alignEnd place a child across a column. alignTop, alignMid, and alignBottom place it down a row. An alignment modifier positions the node it is applied to inside its parent. alignMid on a row moves the row; to centre the row’s children, put alignMid on each child.

alignStart: the left edge
alignStartExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    void (buttonWith (alignStart . fixedW 140) "alignStart")
A button aligned to the left edge of a panel.
alignCenter: the horizontal centre
alignCenterExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    void (buttonWith (alignCenter . fixedW 140) "alignCenter")
A button centred horizontally in a panel.
alignEnd: the right edge
alignEndExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    void (buttonWith (alignEnd . fixedW 140) "alignEnd")
A button aligned to the right edge of a panel.
alignTop: the top edge
alignTopExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    rowWith (fillW . fillH . gap 8) $
      void (buttonWith (alignTop . fixedW 140) "alignTop")
A button aligned to the top of a tall row.
alignMid: the vertical centre
alignMidExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    rowWith (fillW . fillH . gap 8) $
      void (buttonWith (alignMid . fixedW 140) "alignMid")
A button centred vertically in a tall row.
alignBottom: the bottom edge
alignBottomExample =
  panelWith (fixedWH 300 110 . padAll 10) $
    rowWith (fillW . fillH . gap 8) $
      void (buttonWith (alignBottom . fixedW 140) "alignBottom")
A button aligned to the bottom of a tall row.
alignBaseline: share the first baseline
alignBaselineExample =
  rowWith (gap 12 . fixedW 340) $ do
    labelWith (fontSize 24 . alignBaseline) "Title"
    labelWith alignBaseline "baseline text"
A large and a small label lined up on their baselines.
wrap: start a new line when a child does not fit
wrapExample =
  rowWith (wrap . gap 6 . fixedW 280) $
    mapM_ (void . button) ["haskell", "immediate", "mode", "layout", "sdl", "widgets"]
Buttons wrapped onto two lines.
lineGap: space between wrapped lines
lineGapExample =
  columnWith (fillW . gap 16) $ do
    let labels = ["haskell", "immediate", "mode", "layout", "sdl", "widgets"]
    muted "lineGap 2"
    rowWith (wrap . lineGap 2 . gap 6 . fixedW 280) $ mapM_ (void . button) labels
    muted "lineGap 18"
    rowWith (wrap . lineGap 18 . gap 6 . fixedW 280) $ mapM_ (void . button) labels
Two wrapped rows comparing a small and a large line gap.
lineAlign: align wrapped lines in the container
lineAlignExample =
  rowWith (wrap . lineAlign LinesCenter . gap 6 . fixedW 210) $
    mapM_ (void . button) ["alpha", "beta", "gamma", "delta", "epsilon"]
Wrapped buttons with the final line centred.

Positioning and pointer behavior

pinAt takes a child out of the flow. pointer sets whether an overlapping region consumes pointer events (PointerBlock) or passes them to what is underneath (PointerPass). The pointer examples park the pointer over the overlay.

pinAt: position absolutely inside the parent
pinAtExample =
  panelWith (fixedWH 280 110 . padAll 12) $ do
    heading "Inbox"
    muted "The badge is pinned, not stacked."
    let badgeCol = colorRGB 204 64 64
    styled (panelStyle (background badgeCol . borderColor badgeCol)) $
      panelWith (pinAt 6 (-6) . alignEnd . alignTop . padXY 6 1) $
        labelWith (fontColor colorWhite) "12"
A count badge pinned to the top-right corner of a panel.
pointer PointerBlock: consume pointer events over a region
pointerBlockExample =
  layers $ do
    void (buttonWith (fixedWH 200 44) "Open logs")
    styled (panelStyle (background (colorRGBA 0 0 0 150) . borderWidth 0)) $
      panelWith (pinAt 0 0 . grow . pointer PointerBlock . padAll 6) $
        muted "scrim"
A dimming scrim over a button labelled Open logs.
pointer PointerPass: let pointer events through
pointerPassExample =
  layers $ do
    void (buttonWith (fixedWH 200 44) "Open logs")
    labelWith (pinAt 8 (-6) . pointer PointerPass) "decorative label"
A decorative label over a button that still receives hover.

Scrolling

A scroll container needs a bounded viewport on its scrolling axis:

itemList :: [Text] -> NanoUI ()
itemList items =
  scrollWith (fixedH 300 . fillW) $
    column $ for_ items label

Without a bounded ancestor, grow can leave a scroller with nothing to size against.

  • scrollWith returns the body’s result.
  • scrollArea also returns the scroller’s WidgetId, for programmatic control.
  • scroll2DWith / scrollArea2D scroll both axes.
  • A scroller that fills both axes (grow) drops its well and paints the page background (pageBackground, themeWindow by default). Scope a view in styled panelPage for a page on the panel surface.

Virtualization

For 10,000+ rows, build only the visible slice and use spacers for the rest. Capture the scroller id and read the previous layout with getScrollMetricsUi:

virtualizedList :: [Item] -> NanoUI ()
virtualizedList items = do
  sid <- currentId
  scrollWith (fixedH 500 . fillW) $ do
    metrics <- getScrollMetricsUi sid
    let rowH = 28
        total = length items
        (offsetY, viewH) = case metrics of
          Nothing -> (0, 0)
          Just m  -> (v2Y (scrollOffset m), rectH (scrollViewport m))
        firstVisible = max 0 (floor (offsetY / rowH))
        lastVisible  = min total (firstVisible + ceiling (viewH / rowH) + 2)
        topH    = fromIntegral firstVisible * rowH
        bottomH = fromIntegral (total - lastVisible) * rowH
        visibleRows = take (lastVisible - firstVisible) (drop firstVisible items)
    columnWith (tight . fillW) $ do
      when (topH > 0) (spacer Fit (Fixed topH))
      for_ visibleRows $ \item ->
        withKey (itemId item) $
          labelWith (fixedH rowH . fillW) (itemTitle item)
      when (bottomH > 0) (spacer Fit (Fixed bottomH))

scrollViewport excludes padding and the scrollbar lane; use it instead of the container rectangle for the visible height. scrollOffset is the current offset. The metrics read registers a layout dependency, so a resize triggers another frame.

See packages/nano-ui-demo/examples/LongList.hs (100,000 rows) and Logs.hs (sticky-to-bottom).

Programmatic scrolling and wheel tuning

reveal :: WidgetId -> Float -> Int -> Bool -> NanoUI ()
reveal sid rowH index shouldReveal =
  when shouldReveal $
    scrollRectIntoViewUi sid (Rect 0 (fromIntegral index * rowH) 1 rowH) ScrollNearest ScrollInstant

From a view: scrollToUi, scrollByUi, scrollPagesUi, scrollRectIntoViewUi, setScrollOffsetUi, setScrollStepUi. The Context variants (scrollTo, getScrollOffset2D, …) are for IO.

One wheel notch scrolls 60 logical pixels by default. For fixed-height rows, set the step so a notch is one row (askContext is in NanoUI.Monad):

oneRowPerNotch :: Float -> NanoUI ()
oneRowPerNotch rowHeight = do
  ctx <- askContext
  liftIO $ setScrollTuning ctx defaultScrollTuning
    {scrollWheelStep = rowHeight, scrollSmoothTime = 0}

setScrollStepUi sid px overrides one scroller; 0 restores the context step.

Nesting and chrome

scrollArea, panel, and card each draw a background, border, and radius. Nesting them inside a popup or dialog draws a box in a box. Draw the chrome at one level only: flatten the inner surface with styled (inputStyle (background colorTransparent . borderWidth 0)), or give the outer dialog padAll 0 and pad the inner body.

See Styling Gotchas and Troubleshooting for the recurring cases.