It is tempting to approach a user interface framework by scanning the widget catalog first. In Fission, that usually leads to the wrong mental model.
The better starting point is this question: what information needs to be visible, and how should that information react when the viewport changes?
Once you answer that, the container choices become much easier. Most Fission screens are built from a small set of layout ideas: rows, columns, grids, stacking layers, and scroll regions. Higher-level widgets help, but they work best after the screen structure is already clear.
This guide explains how to think about screen composition in Fission, including responsive and adaptive layout for phone, tablet, desktop, and web-sized surfaces.
Start with the screen's reading order
Fission widgets do not own your product state. They read it and return a user interface description. That means the screen structure should follow user meaning first.
Ask these questions before you choose containers:
What should the user read first?
What should stay visible while they work?
Which parts should disappear, collapse, or move on narrower viewports?
Which changes come from app state, and which come from the viewport itself?
That is the foundation of responsive design in Fission. You are not only placing boxes. You are deciding how one state-driven screen should adapt across several hosts.
Responsive decisions can be declarative or imperative
Fission supports two shapes for responsive layout decisions.
Use declarative responsive layout when the rule is about available width: switch this region below a breakpoint, clamp this pane between sensible bounds, or let a child size itself relative to its parent. Responsive currently evaluates width. Read view.viewport_size().height when a decision genuinely depends on height.
Use imperative layout when the rule needs ordinary Rust control flow: combine viewport size with app state, environment values, previous layout geometry, permissions, feature flags, or product-specific state that cannot be expressed as a simple responsive case.
Both forms still follow the same lifecycle rule: widget conversion is pure. Read the current inputs, choose the structure, and return a widget tree. Do not fetch data, write files, mutate global state, or start host work because a layout branch reconverted.
When you need imperative inputs, read them through ViewHandle:
view.viewport_size() for current viewport width and height,
view.env.window_insets for safe-area style insets,
view.state() for product-driven decisions such as whether a detail panel is open,
view.get_rect(...) when a later frame needs previous geometry, such as a popover anchor.
BuildCtxHandle has a different job. It is for wiring actions, resources, portals, animations, and other runtime-managed behavior. Do not use BuildCtxHandle as a place to discover layout facts. Read layout inputs from ViewHandle, then use BuildCtxHandle only when the runtime needs registration or wiring information.
A concrete responsive example
Imagine a mail screen. On a phone, you usually want one column: either the thread list or the selected thread, because both side by side would be cramped. On a larger desktop or web viewport, you usually want a list on the left and a detail panel on the right.
The product rule is the same regardless of syntax:
the selected thread is still app state,
the phone layout still shows one primary surface,
the desktop layout still shows list and detail side by side,
SafeArea still protects the screen boundary,
Scroll still belongs around the region that may exceed the viewport.
{"id":"fission-tabs-1","tabs":[{"index":0,"label":"Declarative syntax","value":"declarative"},{"index":1,"label":"Imperative syntax","value":"imperative"}]}
{"id":"fission-tabs-1","index":0,"label":"Declarative syntax","value":"declarative"}
Use this form when the responsive rule is a straightforward layout concern. The breakpoint and width constraint stay in Fission's layout model, while app state still decides which content appears.
use fission::prelude::*;
const PHONE_BREAKPOINT: f32 = 900.0;
const LIST_PANE_MIN_WIDTH: f32 = 280.0;
const LIST_PANE_MAX_WIDTH: f32 = 360.0;
pub struct MailScreen;
impl From<MailScreen> for Widget {
fn from(_screen: MailScreen) -> Widget {
SafeArea {
id: Some(WidgetId::explicit("mail.screen.safe_area")),
child: Responsive::new(MailDesktopLayout)
.id(WidgetId::explicit("mail.screen.responsive"))
.case(ResponsiveCase::max_width(PHONE_BREAKPOINT, MailPhoneLayout))
.into(),
}
.into()
}
}
pub struct MailPhoneLayout;
impl From<MailPhoneLayout> for Widget {
fn from(_layout: MailPhoneLayout) -> Widget {
let (_ctx, view) = fission::build::current::<MailState>();
let content: Widget = if let Some(thread_id) = view.state().selected_thread {
ThreadDetail { thread_id }.into()
} else {
ThreadList.into()
};
Scroll {
id: Some(WidgetId::explicit("mail.phone.scroll")),
child: Some(content),
..Default::default()
}
.into()
}
}
pub struct MailDesktopLayout;
impl From<MailDesktopLayout> for Widget {
fn from(_layout: MailDesktopLayout) -> Widget {
let (_ctx, view) = fission::build::current::<MailState>();
let spacing = &view.env().theme.tokens.spacing;
let detail: Widget = if let Some(thread_id) = view.state().selected_thread {
ThreadDetail { thread_id }.into()
} else {
EmptyThreadState.into()
};
Row {
id: Some(WidgetId::explicit("mail.desktop.split")),
gap: Some(spacing.m),
children: widgets![
Container::new(ThreadList)
.width_length(Length::clamp(
Length::points(LIST_PANE_MIN_WIDTH),
Length::percent(32.0),
Length::points(LIST_PANE_MAX_WIDTH),
))
.flex_shrink(0.0)
.into(),
Container::new(detail).flex_grow(1.0).into(),
],
..Default::default()
}
.into()
}
}
Responsive::new(MailDesktopLayout) makes the desktop layout the fallback. The max_width(PHONE_BREAKPOINT, MailPhoneLayout) case overrides that fallback on narrow viewports. Cases are explicit and ordered, and the first matching case wins.
Length::clamp(...) replaces manual width math. The list pane wants to be 32% of its parent row, but never smaller than 280 points or larger than 360 points. Use Length::percent(32.0) for parent-relative sizing. Use Length::vw(32.0) only when the rule truly means 32% of the viewport.
The phone and desktop branches are named widgets. That keeps retained widget identity clearer, makes the examples easier to test, and avoids turning screen composition into a collection of helper functions that return anonymous widget trees.
Fission lowers every branch so it can switch layouts deterministically. Construct each branch independently rather than cloning a Widget into several cases, and keep explicit WidgetId values unique across the complete responsive tree.
{"id":"fission-tabs-1","index":1,"label":"Imperative syntax","value":"imperative"}
Use this form when the responsive rule needs raw viewport information or must combine layout facts with product logic that is clearer in Rust. The calculation is manual, but the conversion is still pure.
use fission::prelude::*;
const PHONE_BREAKPOINT: f32 = 900.0;
const LIST_PANE_MIN_WIDTH: f32 = 280.0;
const LIST_PANE_MAX_WIDTH: f32 = 360.0;
pub struct MailScreen;
impl From<MailScreen> for Widget {
fn from(_screen: MailScreen) -> Widget {
let (_ctx, view) = fission::build::current::<MailState>();
let viewport = view.viewport_size();
let spacing = &view.env().theme.tokens.spacing;
let is_phone = viewport.width < PHONE_BREAKPOINT;
let list: Widget = ThreadList.into();
let detail: Widget = if let Some(thread_id) = view.state().selected_thread {
ThreadDetail { thread_id }.into()
} else {
EmptyThreadState.into()
};
let body: Widget = if is_phone {
let content = if view.state().selected_thread.is_some() {
detail
} else {
list
};
Scroll {
id: Some(WidgetId::explicit("mail.phone.scroll")),
child: Some(content),
..Default::default()
}
.into()
} else {
Row {
id: Some(WidgetId::explicit("mail.desktop.split")),
gap: Some(spacing.m),
children: widgets![
Container::new(list)
.width_length(Length::clamp(
Length::points(LIST_PANE_MIN_WIDTH),
Length::percent(32.0),
Length::points(LIST_PANE_MAX_WIDTH),
))
.flex_shrink(0.0)
.into(),
Container::new(detail).flex_grow(1.0).into(),
],
..Default::default()
}
.into()
};
SafeArea {
id: Some(WidgetId::explicit("mail.screen.safe_area")),
child: body,
}
.into()
}
}
view.viewport_size() gives this conversion the current viewport. The screen then decides whether side-by-side work is realistic. App state decides whether the detail panel has a selected thread or should show an empty state.
This style is useful when you need to branch on viewport height, combine geometry with product state, or inspect previous-frame layout. Imperative branching does not require imperative sizing: the desktop branch still uses Length::clamp.
{"id":"fission-tabs-1"}
The important part is not the exact breakpoint number. The important part is why the layout changes.
The viewport decides how much context can fit comfortably. App state decides what content exists. SafeArea protects the content from notches, system bars, and other platform insets. Scroll is only used for the part that may exceed the viewport.
For new code, prefer the declarative form when a ResponsiveCase and Length can express the rule directly. Keep the imperative form for cases where raw geometry or combined product logic genuinely makes the screen easier to understand.
What changes between phone, tablet, desktop, and web layouts
A responsive Fission app is usually not four separate screens. It is one screen that changes how much can fit comfortably at once.
On a phone, space is narrow and vertical scrolling is normal. Single-column layouts, modal flows, drawers, and full-width controls are common. This is also the place where safe areas matter most visibly.
On a tablet, you often gain room for split layouts, but not always enough room for permanently visible sidebars and dense tool chrome. Tablet layouts are often transitional: they may show a list and detail view together, but still simplify spacing and panel counts compared with desktop.
On desktop and larger web surfaces, you usually have room for persistent navigation, inspectors, sidebars, or wider data views. That does not mean everything should spread forever. Good desktop and web layouts still use width constraints so text blocks, forms, and tables remain readable.
The shared rule is simple: more space should reveal more useful context, not just create longer lines and emptier containers.
Rows and columns are the default tools because most screens are still linear in one direction.
Use a row when items should stay side by side and horizontal comparison matters. Toolbars, split panes, label-value pairs, and desktop navigation chrome often begin as rows. Avoid rows on narrow phone layouts unless you are sure the content will still fit or you already have a fallback branch.
Use a column when content has a natural reading order from top to bottom. Forms, article-like screens, mobile settings pages, and stacked control groups usually fit here. Columns are often the most stable starting point for a new screen because they degrade well on narrow viewports.
Use grids when you have repeated content that benefits from a matrix instead of a list. Dashboards, card galleries, calendars, and dense option sets are typical cases. Do not use a grid just because it looks visually balanced. If the content is really a form or a linear reading flow, a column is usually easier to adapt.
Use stacking layers such as ZStack, Overlay, or higher-level widgets built on portals when content needs to occupy the same visual area. Modals, popovers, tooltips, temporary banners, and floating action surfaces belong here. Do not build a whole page out of stacked layers when a row or column would express the structure more clearly.
Use scroll regions when content can legitimately exceed the viewport. A mail list, long settings screen, or document area should scroll. A small form that only overflows because of poor spacing or fixed widths usually needs better layout, not immediate scrolling.
Fission gives you both low-level primitives and higher-level authoring widgets.
The low-level primitives from the core user interface layer, such as Row, Column, Container, Grid, Scroll, Spacer, Overlay, and SafeArea, are the best place to begin because they map directly to the layout engine.
Higher-level widgets from the Fission facade, such as HStack, VStack, SimpleGrid, SplitView, Modal, Drawer, Popover, Tooltip, Tabs, and DataTable, become useful once the layout intention is already clear. They save time when the pattern is stable. They are not a replacement for understanding how the screen is composed.
A good rule is this: if you are still discovering the page structure, start with primitives. If the pattern is already obvious and repeated, use a higher-level widget that expresses that pattern directly.
Width constraints matter more than people expect
Responsive layout is not only about breakpoints. It is also about restraint.
Very wide containers can make forms awkward, body text tiring to read, and side panels visually weak. Very narrow fixed widths can cause text clipping and cramped controls. In Fission, it is common to combine flexible containers with clamps or explicit widths for the parts that should not grow forever.
You saw that in the mail example when the list pane width was clamped. Both versions use Length::clamp(Length::points(280.0), Length::percent(32.0), Length::points(360.0)), because sizing remains a layout constraint even when the surrounding screen structure is chosen imperatively.
Prefer Length when the rule is a layout constraint. Reach for manual arithmetic when the rule genuinely depends on raw viewport geometry or other product logic. Either way, the pane is allowed to respond to available space, but only inside a reasonable range.
That pattern scales well across targets. Let the overall shell breathe, but cap the pieces that need stable readability.
Safe areas and viewport size are different things
view.viewport_size() tells you how much total surface the app currently has. Safe-area insets tell you which parts of that surface should not be treated as ordinary content space because a notch, home indicator, or system bar occupies them.
On desktop, safe areas may not matter much. On mobile, they often matter a lot. SafeArea exists so you can keep content readable without manually subtracting inset values everywhere.
Best practice is to use SafeArea near the screen boundary for major content regions, then use ordinary layout containers inside it. Do not scatter manual inset math through every child widget unless you are building a very specialized surface.
Common responsive mistakes
One common mistake is deciding layout only from viewport size and forgetting app state. A mail screen with no selected thread does not need the same detail region as one with an active thread.
Another common mistake is forcing desktop layouts onto phones. A three-column screen may look impressive in a static mockup and unusable in practice.
A third mistake is using fixed sizes everywhere. Fixed widths and heights are sometimes necessary, but a layout made entirely of hard-coded dimensions usually breaks early across target sizes.
Another frequent issue is nesting scroll regions without a clear reason. That can make touch, wheel, and keyboard navigation confusing. Try to keep the scroll story obvious.
Finally, avoid putting layout side effects into component conversion. Do not fetch data because the viewport changed. Do not write files or mutate global state because a branch reconverted. Keep layout choices pure, and let reducers or explicit runtime resources handle outside work.
A practical workflow for new screens
Start with a single-column version of the screen. Make the state flow and reading order correct first.
Then decide which additional context is worth revealing on wider surfaces. Add rows, side panels, or grids only where they improve the job the user is doing.
After that, validate the screen on a desktop-sized viewport and a narrow mobile-sized viewport. If the feature is headed for web or mobile, test the real host path after the shared behavior is stable.
This workflow matches the architecture of Fission itself. The app model stays shared. The layout adapts through pure decisions in component conversion. Platform-specific validation happens when platform-specific questions are actually present.
Where to go next
If you want to understand how input, viewport data, locale, and theme reach widgets, read Input, text, and environment. If you want the beginner explanation of ViewHandle, BuildCtxHandle, and selectors first, go back to Runtime model. For a live tour of built-in widgets, open the public Examples page and start with Widget Gallery. What you should have working
After this guide, you should be able to explain where Layout and widgets fits in the Fission lifecycle, identify the file or component you changed, run the relevant fission command, and verify the result in a real target or test.
Common mistakes to check
| |
|---|
The app compiles but nothing changes | Confirm the component is actually used by the route or shell target you are running. |
An action fires but state does not update | Confirm the reducer is registered with the same action value that the UI dispatches. |
A platform feature reports unsupported | Confirm the target has the capability in fission.toml and the shell registered the provider. |
The page works on one target but not another | Run fission doctor, then inspect target-specific configuration and feature flags. |
| Split the product rule into a reducer test first, then add a UI or shell smoke test for the integration. |