Modal
Modal presents a short task or decision above the current screen. Application
state owns whether it is open and reducers own every outcome; the dialog never
silently mutates product state or assumes that an action succeeded.
Use the compact Modal fields for a title, body, and action list. Use
ModalLayout when the dialog needs a semantic description, stable named
regions, custom title or description presentation, header accessories,
arbitrary footer controls, or explicit control over the body scrollbar. Both
APIs lower through the same modal recipe.
Use a modal when the user must resolve the interruption before continuing. Use
a Popover for lightweight content anchored to a
control, or a Drawer for a larger temporary side
panel. Example
use fission::prelude::*;
let confirm_modal: Widget = Modal {
id: WidgetId::explicit("publish-modal"),
title: "Publish this release?".into(),
content: Text::new(
"Everyone on your team will see this version immediately.",
)
.into(),
is_open: view.state().show_publish_modal,
on_dismiss: Some(close_publish_modal.clone()),
backdrop_semantics_identifier: Some("publish-modal.backdrop".into()),
close_semantics_identifier: Some("publish-modal.close".into()),
surface_semantics_identifier: Some("publish-modal.surface".into()),
actions: vec![
ModalAction {
label: "Cancel".into(),
on_press: Some(close_publish_modal),
is_primary: false,
semantics_identifier: Some("publish-modal.cancel".into()),
},
ModalAction {
label: "Publish".into(),
on_press: Some(confirm_publish),
is_primary: true,
semantics_identifier: Some("publish-modal.publish".into()),
},
],
width: None,
motion: Some(ModalMotion::Default),
}
.into();
The ordinary dismissal paths share one action, while each footer action remains
a separate application intent. A reducer decides whether any of those intents
actually closes the modal.
Named anatomy
use fission::prelude::*;
let cancel_action = ModalFooterAction {
label: "Cancel".into(),
on_press: Some(close_invite_modal.clone()),
variant: ButtonVariant::SecondaryGray,
semantics_identifier: Some("invite-modal.cancel".into()),
};
let invite_action = ModalFooterAction {
label: "Send invitation".into(),
on_press: Some(send_invitation),
variant: ButtonVariant::Primary,
semantics_identifier: Some("invite-modal.send".into()),
};
let invite_modal: Widget = ModalLayout {
id: WidgetId::explicit("invite-modal"),
header: ModalHeader::new("Invite a teammate")
.description("They will receive an invitation by email."),
content: ModalContent::new(invite_form),
footer: Some(ModalFooter::new(vec![cancel_action, invite_action])),
is_open: view.state().show_invite_modal,
on_dismiss: Some(close_invite_modal),
close_label: Some(TextContent::KeyWithFallback {
key: "dialogs.invite.close".into(),
fallback: "Close invitation dialog".into(),
}),
motion: Some(ModalMotion::Default),
..Default::default()
}
.into();
Custom visuals do not have to replace the dialog's accessible text or named
relationships:
use fission::prelude::*;
let header = ModalHeader {
leading: Some(context_icon),
title: ModalTitle::custom(
TextContent::KeyWithFallback {
key: "dialogs.review.title".into(),
fallback: "Review changes".into(),
},
illustrated_title,
)
.semantics_identifier("review-modal.title"),
description: Some(
ModalDescription::custom(
"Check the affected records before continuing.",
supporting_copy,
)
.semantics_identifier("review-modal.description"),
),
trailing: Some(review_status_badge),
};
let footer = ModalFooter {
children: vec![remember_review_choice],
actions: vec![cancel_action, apply_action],
};
let review_modal: Widget = ModalLayout {
id: WidgetId::explicit("review-modal"),
header,
content: ModalContent::new(review_form),
footer: Some(footer),
is_open: view.state().show_review_modal,
on_dismiss: Some(close_review_modal),
..Default::default()
}
.into();
ModalTitle::custom(accessible_text, child) and
ModalDescription::custom(accessible_text, child) render the retained child
while preserving the supplied text as the dialog's accessible name or
description. ModalHeader::leading(...) places content at the logical start;
.trailing(...) places it after the title group and before the generated close
button. Use ModalFooter::custom(children) for a footer made entirely from
arbitrary retained controls, or its public children and actions fields when
both are needed.
The complete recipe constructs these regions:
A viewport-sized dimmed and blurred scrim.
A centered, viewport-bounded dialog surface.
A stable header with optional leading content, title, optional description,
optional trailing content, and close control.
The only scrolling region, supplied through ModalContent.
An optional stable footer containing arbitrary controls, ModalFooterAction
buttons, or both.
| |
|---|
| Owns the complete controlled lifecycle and the retained header, content, and optional footer. new(id, title, content) creates the required regions. |
| Owns optional leading content, one required ModalTitle, optional ModalDescription, and optional trailing content before the close control. new and description accept literal or localized TextContent. |
| Owns localizable accessible title text, optional custom retained presentation, and optional semantic node identity and identifier. |
| Owns localizable accessible supporting text, optional custom retained presentation, and optional semantic node identity and identifier. |
| Owns the retained body and its overflow scrollbar, which is visible by default. |
| Owns arbitrary retained children and explicit ModalFooterAction values. An absent footer is omitted from the surface. |
The active theme.components.modal recipe controls scrim and surface fills,
border, radius, padding, gap, maximum width, shadows, blur, viewport margin, and
action-stack breakpoint. It also owns each region's padding, fill, border,
radius, shadows, and gap; title and description typography; and close-control
geometry. The default footer uses a subtle surface tint and one full-width top
boundary. A footer recipe border is that boundary, not a four-sided footer
stroke, so it never adds unintended side or bottom borders. Footer content
padding stays independent from the boundary and the surface composition keeps
the footer full bleed without negative layout margins. Every footer action uses
the normal button component recipe. Change the DSP when the product's modal
anatomy should change globally; use width only for a dialog-specific
constraint.
Field reference
| | | |
|---|
| | Stable identity for portal, focus, and motion slots. | Required and unique for each retained modal. |
| | Visible dialog heading and semantic name. | Adapted to a retained ModalTitle; use ModalLayout for localized title content or a supporting description. |
| | | Adapted to ModalContent, making it the only scrolling region. |
| | | With no motion, false registers no portal. With motion, an exiting visual may remain mounted but is interaction-inert. |
| | Shared backdrop, close-button, and keyboard dismissal action. | None removes those dismissal actions and the generated close button. |
backdrop_semantics_identifier | | Stable test/accessibility identifier for the open scrim action. | Does not make the scrim a Tab stop. |
close_semantics_identifier | | Stable identifier for the generated close button. | Useful when visible icon content is not a stable test selector. |
surface_semantics_identifier | | Stable identifier for the dialog semantic node. | Falls back to the compatibility identifier fission-modal-surface. |
| | Footer actions in logical display order. | An empty vector omits the footer. |
| | | Falls back to the modal recipe and is clamped to the design system's viewport margin, 16 points in the default package. |
| | Optional scrim and surface entrance/exit motion. | Defaults to None; presets include fade, scale, and directional entrances and can be composed. |
ModalLayout replaces title, content, and actions with header: ModalHeader, content: ModalContent, and footer: Option<ModalFooter>. It
retains the same lifecycle, identity, dismissal, semantic-identifier, width,
and motion fields. It additionally exposes close_label: Option<TextContent>
for a dialog-specific, localizable close-control name; the compact Modal
always uses the localizable fission.modal.close key with Close as its
fallback. ModalContent::show_scrollbar defaults to true.
ModalTitle and ModalDescription each expose text: TextContent, an optional
custom child, optional semantic id, and optional semantics_identifier.
Their new(text) constructors use design-system text presentation;
custom(text, child) keeps text authoritative for accessibility while the
child owns presentation. Supply stable identities with struct fields when
another semantic relationship must address those nodes directly;
ModalLayout otherwise derives stable title and description IDs from the modal
ID. The convenience .semantics_identifier(...) builder supports stable shell
and semantic-test identifiers.
ModalHeader exposes leading, title, description, and trailing, with
builders for the optional regions. ModalFooter::new(actions) accepts
Vec<ModalFooterAction> and constructs a standard explicit-variant action
footer; ModalFooter::custom(children) constructs an arbitrary footer. Use its
children and actions fields together for mixed content. ModalAction is the
smaller source-compatible action model used only by the compact Modal API.
Keyboard, focus, and semantics
While open, the surface is a modal Dialog semantic region. It exposes a stable
labelled_by relationship to ModalTitle and, when supplied, described_by to
ModalDescription; the resolved title is also its direct label. The compact
Modal API derives the same title relationship. Its focus barrier keeps
ordinary Tab traversal inside the dialog and restores the previous valid
target when the modal closes. The first eligible descendant marked for
autofocus wins; otherwise the first eligible focusable descendant becomes the
entry target. Put focusable body content in the intended order and use
autofocus only for a deliberate initial destination.
Escape dispatches the topmost open modal's dismissal action. The backdrop is
pointer actionable but deliberately not keyboard focusable. When on_dismiss
creates the icon-only close button, compact Modal resolves the localizable
fission.modal.close key with Close as its fallback. ModalLayout can
override that accessible name through close_label. Closing removes the focus
barrier and makes every dismissal path interaction-inert immediately, even when
Presence keeps the scrim or surface visible for an exit animation. Pointer
events during that exit pass through to underlying content; a hidden or exiting
modal cannot dispatch on_dismiss.
Responsive, overflow, target, and reduced-motion behavior
Width and height never exceed the viewport minus twice the active design
system's modal margin. At widths below the action-stack breakpoint, 640 points
in the default package, footer actions become a vertical stack. Compact
ModalAction values marked primary and ModalFooterAction values using
Filled, Primary, or Destructive variants move ahead of less-emphasized
actions while preserving relative order within each group, followed by custom
footer children. On wider layouts, custom children remain at the logical start
and actions retain logical vector order after flexible space.
The header and footer stay in place while ModalContent shrinks and scrolls
inside the height bound. Disable its visible scrollbar with
show_scrollbar: false only when another affordance makes overflow clear.
Prefer a routed screen for work that is intrinsically long or requires broad
comparison.
The shared portal, semantics, and paint model is available to interactive
shells, including Terminal's constrained cell layout. Plain Static site output
does not provide an interactive modal loop; SSR needs its browser interaction
path for client-side opening and dismissal.
When Env::motion_preference is Reduced, every modal Motion and Presence
track resolves immediately. Closing content disappears on that build unless its
Presence declaration uses keep_rendered; a retained hidden child remains
interaction-inert. The Web canvas shell reads and follows
prefers-reduced-motion unless an application sync_env callback overrides
it; other interactive shells and tests honor an Env value supplied through
with_env or with_sync_env. Static site and SSR output emit immediate
final-state animation and transitions for the same browser preference. You can
keep the same ModalMotion preset instead of branching the widget tree by
target.
Production checklist
Use one reducer path for ordinary dismissal and separate actions for distinct
outcomes.
Put destructive actions in a ModalLayout footer as ModalFooterAction
values using ButtonVariant::Destructive; visual hierarchy does not replace
confirmation or reducer-side safety.
Verify Tab, reverse Tab, Escape, focus restoration, backdrop dismissal,
and the no-dismissal configuration.
Exercise long text, validation errors, software keyboard resize, increased
text scale, and narrow action stacking.
Use ModalLayout when supporting text must be related to the dialog or a
named region needs stable identity.
Keep explicit accessible text on every custom title and description, and
verify mixed footer content in both horizontal and stacked layouts.
Choose a full screen when the task is too long or broad for a bounded body
scroller.