Styling
Tela styles elements with the vocabulary of Tailwind CSS v4. There is no cascade, no selectors and no stylesheet: an element’s style comes from three sources, applied in this order, and the last one wins:
- The element’s defaults (a
Buttonhas the pointer cursor and no background; headings are unstyled, like Tailwind’s Preflight). - The classes of
Class, in order of appearance.Classmay repeat; a later class wins over an earlier one that sets the same property. - The typed props of
Style, for values computed in code.
Classes
Section titled “Classes”func Card(title, body string) tela.Node { return Div(Class("flex flex-col gap-2 rounded-xl border border-slate-200 bg-white p-6 shadow-md"), H2(Class("text-lg font-semibold text-slate-900"), Text(title)), P(Class("text-sm leading-6 text-slate-500"), Text(body)), )}What is supported: spacing, sizing, colors (with opacity as bg-white/15),
borders, radius, shadows, gradients (bg-linear-to-br from-indigo-500 to-fuchsia-600), typography, block, flex and grid layout, positioning
(relative, absolute, fixed, sticky; see Positioning),
overflow, transforms, effects (filters, mix-blend-*,
[clip-path:…], see Effects and background images), background images, transitions (transition, duration-*, ease-*, delay-*, also with hover: and dark:), the animate-spin, animate-ping, animate-pulse and animate-bounce classes, and arbitrary values (w-[calc(50%-8px)],
bg-[#1e1e2e]). The vocabulary is generated from Tailwind v4’s theme; the v3
aliases that mean the same thing are accepted (bg-gradient-to-br). The
theme’s semantic colors (bg-primary, text-muted-foreground) and the app’s
class aliases are described in Theming. Custom
keyframes are not implemented
yet; check the roadmap
before relying on them.
A class Tela does not know is not silently ignored: in dev mode (on in every
development build and every telatest test; see
Getting Started) it is reported with the closest
valid class, for example unknown class "rounded-xxl"; did you mean "rounded-2xl"?, and in a test it fails the test.
Class takes several strings and joins them with one space, skipping
the empty ones, so a class held in a variable needs no concatenation:
tone := "text-slate-500"if active { tone = "text-indigo-600 font-semibold"}Div(Class("flex items-center gap-2 rounded-lg px-3 py-1", tone))Classes that depend on state
Section titled “Classes that depend on state”If takes props as well as nodes, so a class that depends on state is one
more prop, without concatenating strings; IfElse picks one of two:
func Tab(label string, active bool) tela.Node { return Button(Class("rounded-md px-3 py-1.5 text-sm"), If(active, Class("bg-white shadow-sm")), IfElse(active, Class("text-slate-900"), Class("text-slate-500 hover:text-slate-700")), Text(label))}Typed style props
Section titled “Typed style props”Static styling belongs in classes. For values that come from code (a measured
width, a color from state), Style takes the typed props of package tela.
They apply after the classes, so they win over a class that sets the same
property. Lengths are logical pixels.
func Meter(width float32, accent tela.Color) tela.Node { return Div(Class("h-2 rounded-full bg-slate-200"), Div(Class("h-2 rounded-full"), Style(tela.Width(width), tela.Bg(accent))), )}A tela.StyleProp is also a child, so an element takes it
directly and If takes it too: Div(tela.Width(width), tela.Bg(accent), If(dim, tela.Opacity(0.5))) is the same as the Style(...) form.
tela.WH(w, h) sets the width and the height, and
tela.Box(x, y, w, h) the left, top, width and height of a
positioned element (a rectangle from ElementRef.Bounds fits it):
Div(Class("absolute rounded-md ring-2 ring-indigo-500"), tela.Box(r.X, r.Y, r.Width, r.Height))WH is not called Size because tela.Size is the type of animated sizes.
For deferred props, h.Scale(v, 36) and
tela.MapValue(v, f) read a Value where the prop is
evaluated, replacing the closure func() float32 { return 36 * v.Get() }:
Style(tela.HeightFn(Scale(open, 36))).
The props, by area:
| Area | Props |
|---|---|
| Size | tela.Width, tela.Height, tela.MinWidth, tela.MinHeight, tela.MaxWidth, tela.MaxHeight |
| Spacing | tela.Padding, tela.PaddingXY, tela.Margin, tela.Gap |
| Position | tela.Left, tela.Top, tela.Right, tela.Bottom, tela.ZIndex |
| Color | tela.Bg, tela.BackgroundGradient, tela.BgImage, tela.TextColor, tela.BorderColor, tela.Opacity |
| Border and shadow | tela.BorderWidth, tela.Radius, tela.BoxShadow |
| Transform | tela.Translate, tela.Scale, tela.Rotate |
| Text | tela.FontSize, tela.FontWeight |
| Deferred (see Hooks) | tela.TranslateFn, tela.OpacityFn, tela.BgFn, tela.TextColorFn, tela.WidthFn, tela.HeightFn |
Colors, gradients and shadows
Section titled “Colors, gradients and shadows”A tela.Color is built with tela.Hex ("#rgb", "#rrggbb", with or without
alpha; it panics on a malformed literal), tela.RGB, tela.RGBA or tela.OKLCH
(the notation of Tailwind v4’s palette). tela.Transparent, tela.Black and
tela.White are predefined. Colors are extended sRGB, so a color outside the
sRGB gamut keeps its components and is clipped when painted, like browsers do.
func Banner() tela.Node { gradient := tela.LinearGradient(135, tela.Stop(tela.Hex("#6366f1")), tela.StopAt(tela.OKLCH(0.667, 0.295, 322.15), 100), ) glow := tela.Shadow{OffsetY: 8, Blur: 24, Color: tela.RGBA(99, 102, 241, 0.4)}
return Div(Class("h-32 rounded-2xl"), Style(tela.BackgroundGradient(gradient), tela.BoxShadow(glow)), )}tela.LinearGradient takes an angle in degrees (0 points up, 90 right, 180
down) and at least two stops from tela.Stop (spread evenly, as in CSS) or
tela.StopAt (a percentage of the gradient line). Colors interpolate in Oklab,
like Tailwind v4.
The same goes for bg-linear-*, bg-radial-* and bg-conic-* with a number or a direction. A gradient you write as CSS text
(bg-[linear-gradient(...)], bg-linear-[to_right,#f00,#00f]) interpolates in sRGB, as CSS does, unless it says
in oklab (bg-[linear-gradient(to_right_in_oklab,#f00,#00f)]). Other color spaces (in oklch, in hsl) are not supported.
Effects and background images
Section titled “Effects and background images”Background images. bg-[url(img/hero.jpg)] loads a file from the file
systems registered with tela.Assets, usually an embedded directory
registered from init, so tela.Run, tela snapshot, tela inspect and
telatest all find it:
//go:embed imgvar images embed.FS
func init() { tela.Assets(images) }
func Hero() tela.Node { return Div(Class("h-64 rounded-2xl bg-[url(img/hero.jpg)] bg-cover bg-center"), H1(Class("p-8 text-4xl font-bold text-white"), Text("Welcome")))}Paths are relative to the root of the file system (/img/hero.jpg and
./img/hero.jpg are the same file). Several Assets calls are searched in
the order they were made. PNG, JPEG, GIF (first frame) and WebP are decoded
once and cached. A missing file paints nothing and is reported in dev mode
with the path and the registered file systems. For an image you already
decoded, use Style(tela.BgImage(img)); the background classes apply to it
too. Sizes (bg-cover, bg-contain, bg-auto, bg-[length:96px_auto]),
positions (bg-center, bg-bottom-right, bg-[position:25%_75%]), repeat
modes (bg-repeat, bg-no-repeat, bg-repeat-x, bg-repeat-y,
bg-repeat-round, bg-repeat-space), bg-origin-* and bg-clip-* work as
in CSS, and bg-[linear-gradient(to_bottom,transparent,rgb(0_0_0/0.6)),url(img/hero.jpg)]
stacks several layers, the first on top. bg-blend-* blends the layers with
each other and with the background color.
SVG files work as background images too (drawn as vectors).
Masks. Tailwind v4’s mask utilities work on any element and its descendants (a mask makes a stacking context). An image mask shows the element where the image is opaque, which tints a monochrome PNG or SVG with any background:
Div(Class("size-5 bg-[#D97757] mask-[url(icons/claude.png)] mask-contain mask-no-repeat"))mask-[url(...)] loads from tela.Assets like bg-[url(...)];
mask-contain, mask-cover, mask-size-[...], the positions (mask-center,
mask-top-left…), the repeat modes, mask-origin-*, mask-clip-*,
mask-luminance and mask-add/subtract/intersect/exclude work as in
CSS. The gradient masks fade edges and shapes: mask-t-from-50%,
mask-b-to-80%, mask-x-from-70% (and r, l, y), mask-linear-45 mask-linear-from-30%, mask-radial-from-40% mask-radial-to-70% with
mask-circle, mask-radial-closest-side and mask-radial-at-top-left, and
mask-conic-from-75%; stops take percentages, spacing numbers, lengths and
colors, and several families intersect, as in Tailwind. Where two mask
classes set the same value (mask-b-from-50% mask-b-from-20%, two angles,
mask-circle mask-ellipse), the last one in the class string wins, as with
every other class, variants included; in Tailwind the order of its
stylesheet decides instead. The edge classes and mask-linear-* set the same
linear mask, so the one written last is used: mask-linear-from-20% mask-b-from-50% fades the bottom edge.
SVG paints. fill-* and stroke-* (colors, tokens, /NN,
fill-current, fill-none) and stroke-2, stroke-[1.5] (stroke width in
SVG units) restyle icons and SVG images; they are inherited, so a class on a
button reaches the icons inside it.
Gradient text. bg-clip-text text-transparent paints the background
through the glyphs of the element’s text, with gradients or images, over
several lines and in inline spans:
H1(Class("bg-linear-to-r from-indigo-500 to-pink-500 bg-clip-text text-5xl font-extrabold text-transparent"), Text("Tela"))Blending. mix-blend-multiply, mix-blend-screen and the other modes
blend an element with what is painted under it in its stacking context; add
isolate (or any other stacking context: relative z-10, opacity, a
transform) to a parent to keep the blend inside it.
Clip paths. Tailwind has no clip-path utility, so it is written as the
arbitrary property, with underscores for spaces:
[clip-path:circle(50%)], [clip-path:ellipse(50%_35%_at_50%_50%)],
[clip-path:inset(8px_round_1rem)],
[clip-path:polygon(0_0,100%_0,100%_80%,0_100%)] (optionally
polygon(evenodd,…)). Shapes are relative to the border box; the clip cuts
everything the element paints (shadows and outline included) and its
clicks. Variants work on it (hover:[clip-path:circle(75%)]), and
transition-[clip-path] animates between shapes of the same kind. No other
arbitrary property is accepted.
Variants
Section titled “Variants”A variant prefix makes a class conditional, as in Tailwind: hover:bg-white/25.
Variants combine (dark:md:hover:bg-slate-700); every part must hold.
| Kind | Variants |
|---|---|
| State | hover: active: focus: focus-visible: focus-within: disabled: enabled: checked: (an element whose AriaChecked is true, such as a checkbox or switch made with the aria hooks; also group-checked: and peer-checked:) |
| ARIA | aria-busy: aria-checked: aria-disabled: aria-expanded: aria-hidden: aria-invalid: aria-pressed: aria-readonly: aria-required: aria-selected:: they match when the element’s AriaBusy(true), AriaChecked(true)… prop is set (Tailwind’s [aria-x="true"]), not an implicit state; aria-[checked=true]: is aria-checked:; aria-[sort=ascending]: and aria-[sort=descending]: match AriaSort; aria-[current=page]: (also step, location, date, time, true) matches AriaCurrent; other arbitrary selectors such as aria-[sort=other]: are not supported |
| Structure | first: last: only: odd: even: |
| Text fields | read-only: placeholder-shown: invalid: placeholder: (style the placeholder text) selection: (style the selected text) |
| Ancestors and siblings | group-hover: and the other state variants after group-, on a descendant of an element with the class group; peer-* for the following siblings of an element with the class peer (named groups and peers are not supported) |
| Environment | dark: motion-reduce: motion-safe: and the breakpoints sm: md: lg: xl: 2xl: (window width at least), max-sm: … max-2xl: (window width below the breakpoint), min-[600px]: and max-[40rem]: (px, rem or em); they stack: md:max-xl:flex-row applies from 768 to 1279 px |
| Direction | rtl: ltr:: the element’s direction, set with Dir on it or an ancestor (see Right to left) |
func Row(name string) tela.Node { return Div(Class("group flex items-center justify-between rounded-lg px-3 py-2 hover:bg-slate-100"), Span(Class("text-slate-900"), Text(name)), Button(Class("rounded-md px-2 py-1 text-sm text-slate-500 opacity-0 "+ "group-hover:opacity-100 hover:bg-slate-200 focus-visible:opacity-100"), Text("Rename")), )}Positioning
Section titled “Positioning”Positioning follows CSS, measured against Chromium:
relativeoffsets a box by its insets;absoluteplaces it against its nearest positioned ancestor (or one with a transform or a filter, which contains positioned descendants in CSS).fixedplaces a box against the window: scrolling the page or any container does not move it, and the overflow clips of its ancestors do not cut it. A transformed or filtered ancestor (translate-x-4,blur-sm,backdrop-blur) becomes its containing block instead, as in browsers. With auto insets it stays at its static position.stickykeeps a box in flow and, while its scroll container scrolls, pushes it inside the scrollport by its insets (top-0,bottom-4,left-0…), never out of its parent: a section header sticks while its section scrolls by and is pushed out by the section’s end. It works inoverflow-autoboxes, in the window, and for the items ofh.VirtualList.
func Page() tela.Node { return Div( Div(Class("sticky top-0 z-10 h-12 bg-white/90 px-4 shadow"), Text("Header")), Div(Class("h-[2000px] p-4"), Text("Content")), Button(Class("fixed right-4 bottom-4 rounded-full bg-indigo-600 px-4 py-2 text-white"), Text("New")), )}For overlays that must cover everything (modals, menus, toasts), render
them through h.Portal, which also escapes the
stacking contexts and z-index of the component that renders them.
Dark mode
Section titled “Dark mode”dark: applies when the color scheme is dark: the system’s preference,
unless the app chooses one with ColorScheme on the tela.App
(tela.Light, tela.Dark; the zero value tela.System follows the system)
or switches at run time with tela.SetColorScheme
(safe from any goroutine). The theme’s semantic colors follow the scheme
by themselves (bg-card text-card-foreground border-border, shadcn/ui’s
names), and h.Scheme
gives part of the window a scheme of its own; see Theming. With
palette colors, write both looks with a class pair; the dark: class
overrides the plain one when dark mode is on:
func Panel() tela.Node { return Div(Class("rounded-xl bg-white p-6 text-slate-900 dark:bg-zinc-900 dark:text-zinc-100"), Text("Hello"))}To check both, render each with the CLI (tela snapshot --dark) or in a test
with telatest.Dark:
package main
import ( "testing"
"github.com/kuronosu/tela/telatest")
func TestPanelDark(t *testing.T) { app := telatest.Mount(t, Panel(), telatest.Size(200, 100), telatest.Dark())
img := app.Screenshot()
if p := img.RGBAAt(100, 5); p.R > 40 { t.Errorf("pixel %v: the dark: class should have made the panel dark", p) }}A component that needs the scheme in Go (to pick an image, say) reads it with
tela.UseColorScheme,
which rebuilds it when the scheme changes; tela.UseReducedMotion
does the same for the system’s reduced-motion setting (motion-reduce:).
A toggle:
func ThemeToggle() tela.Node { dark := tela.UseColorScheme() == tela.Dark next, label := tela.Dark, "Dark mode" if dark { next, label = tela.Light, "Light mode" } return Button(OnClick(func(tela.MouseEvent) { tela.SetColorScheme(next) }), Text(label))}In tests, telatest.Dark() makes the system prefer dark and
telatest.ColorScheme(tela.Dark) sets the app’s scheme.
Set the window background with RootClass on the tela.App, which styles the
root box that fills the window: RootClass: "bg-white dark:bg-zinc-950".
Responsive layout
Section titled “Responsive layout”The breakpoint variants (md:, max-md:, min-[600px]:…) look at the window width, so one tree adapts:
func Gallery() tela.Node { return Div(Class("flex flex-col gap-4 p-4 md:flex-row"), Div(Class("h-24 grow rounded-lg bg-indigo-500")), Div(Class("h-24 grow rounded-lg bg-fuchsia-500")), )}Right to left
Section titled “Right to left”Dir("rtl") on an element lays it and its subtree out right to left, like
HTML’s dir attribute: flex rows start at the right, grid column 1 is the
rightmost, text aligns right, absolutely positioned boxes take their static
position from the right and scroll containers scroll from the right. The
logical classes follow the direction; the physical ones do not:
Logical (start is the right in rtl) |
Physical (never mirrored) |
|---|---|
ms-* me-* ps-* pe-* |
ml-* mr-* pl-* pr-* |
start-* end-* inset-s-* inset-e-* |
left-* right-* |
border-s-* border-e-* (widths and colors) |
border-l-* border-r-* |
rounded-s-* rounded-e-* rounded-ss-* rounded-se-* rounded-es-* rounded-ee-* |
rounded-l-* rounded-tl-* … |
text-start text-end (and the default alignment) |
text-left text-right |
space-x-* (margins on the inline end), justify-start, justify-end, gap-* |
translate-x-*, bg-linear-to-r, origin-left |
The block-direction classes mbs-* mbe-* pbs-* pbe-* inset-bs-*
inset-be-* border-bs-* border-be-* are top and bottom. Use rtl: and
ltr: for what has no logical class, such as turning an arrow:
Button(Class("flex items-center gap-2"), Div(Class("rtl:rotate-180"), Text("←")), // points back in both directions Span(Text(t("back"))),)With i18n, the direction comes from the locale:
Dir(i18n.Dir(i18n.UseLocale()).String()). A component reads the direction at
its position with tela.UseDirection
("ltr" or "rtl"), for what classes cannot mirror, such as arrow keys; the
kit’s components already do.
See also
Section titled “See also”- Elements and Props for the defaults each element has.
- CLI to look at the result as a PNG, and
tela inspect --layoutfor the boxes as JSON. - The
examples/directory:counteris checked pixel by pixel against Chromium rendering the same HTML and Tailwind CSS.