diff --git a/Cargo.lock b/Cargo.lock
index 730cd4419..51efaa346 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -718,6 +718,17 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "components"
+version = "0.0.0"
+dependencies = [
+ "console_error_panic_hook",
+ "time",
+ "wasm-bindgen",
+ "web-sys",
+ "xilem_web",
+]
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -4049,6 +4060,7 @@ checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd"
dependencies = [
"deranged",
"itoa",
+ "js-sys",
"libc",
"num-conv",
"num_threads",
diff --git a/Cargo.toml b/Cargo.toml
index 00cee30df..591151fae 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ members = [
"masonry_winit",
"xilem_web",
+ "xilem_web/web_examples/components",
"xilem_web/web_examples/counter",
"xilem_web/web_examples/counter_custom_element",
"xilem_web/web_examples/elm",
diff --git a/xilem_web/web_examples/components/Cargo.toml b/xilem_web/web_examples/components/Cargo.toml
new file mode 100644
index 000000000..50909d8d3
--- /dev/null
+++ b/xilem_web/web_examples/components/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "components"
+license.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+
+[[bin]]
+name = "components"
+test = false
+doctest = false
+doc = false
+bench = false
+
+[lints]
+workspace = true
+
+[dependencies]
+console_error_panic_hook = "0.1.7"
+time = { workspace = true, features = ["formatting", "local-offset", "macros", "wasm-bindgen"] }
+wasm-bindgen = "0.2.108"
+web-sys = "0.3.85"
+xilem_web = { path = "../.." }
diff --git a/xilem_web/web_examples/components/index.html b/xilem_web/web_examples/components/index.html
new file mode 100644
index 000000000..f764796ef
--- /dev/null
+++ b/xilem_web/web_examples/components/index.html
@@ -0,0 +1,8 @@
+
+
+
+ Components | Xilem Web
+
+
+
+
diff --git a/xilem_web/web_examples/components/src/card.rs b/xilem_web/web_examples/components/src/card.rs
new file mode 100644
index 000000000..1991f3347
--- /dev/null
+++ b/xilem_web/web_examples/components/src/card.rs
@@ -0,0 +1,44 @@
+// Copyright 2026 the Xilem Authors
+// SPDX-License-Identifier: Apache-2.0
+
+use xilem_web::{DomFragment, core::map_action, elements::html, interfaces::Element};
+
+// The `CardAction` is a composition of
+// the variations of the card component
+// (only `Toggle` here so far)
+// and the generic actions `A` of the child.
+pub(crate) enum Action {
+ Toggle,
+ Child(A),
+}
+
+impl xilem_web::Action for Action {}
+
+// This view is generic about its child and its actions.
+// It also has no state of its own,
+// but only communicates with the parents via the [`CardAction`].
+pub(crate) fn view(
+ title: &'static str,
+ collapsed: bool,
+ content: Child,
+) -> impl Element>
+where
+ Child: DomFragment,
+ State: 'static,
+ ChildAction: 'static,
+{
+ let content = map_action(
+ html::div(content)
+ .class("content")
+ .class(collapsed.then_some("hidden")),
+ |_, msg| Action::Child(msg),
+ );
+
+ html::div((
+ html::h3(title)
+ .class("title")
+ .on_click(|_, _| Action::Toggle),
+ content,
+ ))
+ .class("card")
+}
diff --git a/xilem_web/web_examples/components/src/date_picker.rs b/xilem_web/web_examples/components/src/date_picker.rs
new file mode 100644
index 000000000..c6840d42a
--- /dev/null
+++ b/xilem_web/web_examples/components/src/date_picker.rs
@@ -0,0 +1,166 @@
+// Copyright 2026 the Xilem Authors
+// SPDX-License-Identifier: Apache-2.0
+
+use time::{Date, Duration, OffsetDateTime};
+use xilem_web::{elements::html, interfaces::Element, modifiers::style};
+
+#[derive(Debug, Default)]
+pub(crate) struct State {
+ pub selected: Option,
+ pub popup_open: bool,
+ pub shown_month: Option,
+}
+
+pub(crate) enum Action {
+ DateChanged(Option),
+ Cancelled,
+}
+
+impl xilem_web::Action for Action {}
+
+// /// Renders only the calendar popup (always visible, no input field).
+// /// Intended for use in overlays where the caller controls positioning and backdrop.
+pub(crate) fn view(state: &State) -> impl Element + use<> {
+ let clear_button = html::button("Clear").on_click(|state: &mut State, _| {
+ state.selected = None;
+ state.shown_month = None;
+ Action::DateChanged(None)
+ });
+
+ let today_button = html::button("Today").on_click(|state: &mut State, _| {
+ let today = today();
+ state.selected = Some(today);
+ state.shown_month = Some(today);
+ Action::DateChanged(Some(today))
+ });
+
+ let cancel_button = html::button("\u{2716}")
+ .class("ml-auto")
+ .on_click(|_: &mut State, _| Action::Cancelled);
+
+ html::div((
+ html::div((clear_button, today_button, cancel_button)).class("actions"),
+ navigation_bar(state),
+ calendar_grid(state),
+ ))
+ .style((!state.popup_open).then_some(style("display", "none")))
+ .class("date-picker")
+}
+
+fn navigation_bar(model: &State) -> impl Element + use<> {
+ let shown = model.shown_month.unwrap_or_else(today);
+
+ let title = format!("{} {}", shown.month(), shown.year());
+
+ html::div((
+ html::button("<").on_click(|model: &mut State, _| {
+ let cur = model.shown_month.unwrap_or_else(today);
+ model.shown_month = Some(shift_month(cur, -1));
+ }),
+ html::button("\u{00AB}").on_click(|model: &mut State, _| {
+ let cur = model.shown_month.unwrap_or_else(today);
+ model.shown_month = Some(shift_month(cur, -12));
+ }),
+ html::span(title).class("title"),
+ html::button("\u{00BB}").on_click(|model: &mut State, _| {
+ let cur = model.shown_month.unwrap_or_else(today);
+ model.shown_month = Some(shift_month(cur, 12));
+ }),
+ html::button(">").on_click(|model: &mut State, _| {
+ let cur = model.shown_month.unwrap_or_else(today);
+
+ model.shown_month = Some(shift_month(cur, 1));
+ }),
+ ))
+ .class("nav-bar")
+}
+
+fn calendar_grid(model: &State) -> impl Element + use<> {
+ let now = OffsetDateTime::now_local().unwrap();
+ let today = Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap();
+ let shown_month = model.shown_month.unwrap_or(today);
+
+ let start = month_start(shown_month);
+ let mut day = start;
+
+ let weeks = (0..6).map(|_| {
+ let days = (0..7).map(|_| {
+ let cur_day = day;
+ day += Duration::days(1);
+
+ let class = if cur_day.month() != shown_month.month() {
+ Some("not-in-month")
+ } else if Some(cur_day) == model.selected {
+ Some("selected")
+ } else if cur_day == today {
+ Some("today")
+ } else {
+ None
+ };
+
+ html::td(cur_day.day().to_string())
+ .on_click(move |mdl: &mut State, _event| {
+ mdl.selected = Some(cur_day);
+ mdl.popup_open = false;
+ Action::DateChanged(Some(cur_day))
+ })
+ .class("day")
+ .class(class)
+ });
+
+ html::tr(days.collect::>())
+ });
+
+ let header = html::tr(WEEKDAYS.iter().map(|d| html::th(*d)).collect::>());
+
+ html::table((html::thead(header), html::tbody(weeks.collect::>()))).class("calendar")
+}
+
+const WEEKDAYS: [&str; 7] = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
+
+fn month_start(date: Date) -> Date {
+ let first = Date::from_calendar_date(date.year(), date.month(), 1).unwrap();
+ let weekday = first.weekday().number_from_monday();
+ first - Duration::days(i64::from(weekday - 1))
+}
+
+fn shift_month(base: Date, months: i16) -> Date {
+ let mut year = base.year();
+ let mut month = base.month() as i16 + months;
+
+ while month > 12 {
+ month -= 12;
+ year += 1;
+ }
+ while month < 1 {
+ month += 12;
+ year -= 1;
+ }
+
+ let day = base
+ .day()
+ .min(days_in_month(year, u8::try_from(month).unwrap()));
+ let month = time::Month::try_from(u8::try_from(month).unwrap()).unwrap();
+
+ Date::from_calendar_date(year, month, day).unwrap()
+}
+
+fn days_in_month(year: i32, month: u8) -> u8 {
+ match month {
+ 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
+ 4 | 6 | 9 | 11 => 30,
+ 2 => {
+ if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
+ 29
+ } else {
+ 28
+ }
+ }
+ _ => unreachable!(),
+ }
+}
+
+fn today() -> Date {
+ let now = OffsetDateTime::now_local().unwrap();
+ Date::from_calendar_date(now.year(), now.month(), now.day()).unwrap()
+}
diff --git a/xilem_web/web_examples/components/src/main.rs b/xilem_web/web_examples/components/src/main.rs
new file mode 100644
index 000000000..0a7a4bbd3
--- /dev/null
+++ b/xilem_web/web_examples/components/src/main.rs
@@ -0,0 +1,102 @@
+// Copyright 2026 the Xilem Authors
+// SPDX-License-Identifier: Apache-2.0
+
+//! This example shows how to create generic components.
+
+use time::{Date, format_description::FormatItem, macros::format_description};
+use xilem_web::{
+ App,
+ core::{MessageResult, map_action, map_message_result, map_state},
+ document_body,
+ elements::html,
+ interfaces::Element,
+};
+
+// These modules could also be in an external crate.
+mod card;
+mod date_picker;
+
+const DATE_FORMAT: &[FormatItem<'static>] = format_description!("[year]-[day]-[month]");
+
+#[derive(Default)]
+struct AppState {
+ clicks: i32,
+ card_collapsed: bool,
+ date: Option,
+ date_picker: date_picker::State,
+}
+
+impl AppState {
+ fn click(&mut self) {
+ self.clicks += 1;
+ }
+
+ fn toggle_card(&mut self) {
+ self.card_collapsed = !self.card_collapsed;
+ }
+
+ fn show_date_picker(&mut self) {
+ self.date_picker.popup_open = true;
+ }
+
+ fn close_date_picker(&mut self) {
+ self.date_picker.popup_open = false;
+ }
+}
+
+fn app_logic(state: &mut AppState) -> impl Element + use<> {
+ let counter = html::button("click me!").on_click(|state: &mut AppState, _| state.click());
+
+ let card_content = html::div(("Some content ... ", state.clicks, counter));
+
+ let card = card::view("Card Example", state.card_collapsed, card_content);
+ let card = map_action(card, map_card_action);
+
+ let date_input = html::input(())
+ .attr(
+ "value",
+ state
+ .date
+ .map(|date| date.format(DATE_FORMAT).unwrap())
+ .unwrap_or_default(),
+ )
+ .class("date")
+ .on_focus(|state: &mut AppState, _| state.show_date_picker());
+
+ let date_picker = date_picker::view(&state.date_picker);
+ let date_picker = map_state(date_picker, |state: &mut AppState| &mut state.date_picker);
+ let date_picker = map_message_result(date_picker, handle_date_picker_message_result);
+
+ html::div((card, html::div((date_input, date_picker))))
+}
+
+fn map_card_action(state: &mut AppState, action: card::Action<()>) {
+ match action {
+ card::Action::Toggle => state.toggle_card(),
+ card::Action::Child(()) => {}
+ }
+}
+
+fn handle_date_picker_message_result(
+ state: &mut AppState,
+ message_result: MessageResult,
+) -> MessageResult<()> {
+ let MessageResult::Action(action) = message_result else {
+ return message_result.map(|_| ());
+ };
+ match action {
+ date_picker::Action::DateChanged(date) => {
+ state.date = date;
+ state.close_date_picker();
+ }
+ date_picker::Action::Cancelled => {
+ state.close_date_picker();
+ }
+ }
+ MessageResult::Action(())
+}
+
+fn main() {
+ console_error_panic_hook::set_once();
+ App::new(document_body(), AppState::default(), app_logic).run();
+}
diff --git a/xilem_web/web_examples/components/style.css b/xilem_web/web_examples/components/style.css
new file mode 100644
index 000000000..9913ccf8a
--- /dev/null
+++ b/xilem_web/web_examples/components/style.css
@@ -0,0 +1,98 @@
+html,body {
+ font-family: sans-serif;
+}
+
+.hidden {
+ display: none;
+}
+
+.card {
+ box-shadow: 1px 1px 3px #555;
+ border-radius: 5px;
+}
+
+.card .title {
+ cursor: pointer;
+ margin: 0;
+ color: #444;
+ background: #ddd;
+ border-radius: 5px 5px 0 0;
+ border-bottom: 1px solid #aaa;
+ padding: 0.2em 0.5em;
+}
+
+.card .content {
+ color: #333;
+ background: #eee;
+ border-radius: 0 0 5px 5px;
+ padding: 1em;
+}
+
+.card button {
+ margin: 0.3em 0.7em;
+ padding: 0.3em 0.5em;
+}
+
+input.date {
+ margin: 2em 0;
+ padding: 0.3em 0.5em;
+ border-width: 1px;
+ border-radius: 3px;
+}
+
+.date-picker {
+ display: grid;
+ max-width: 300px;
+ background-color: #eee;
+ border-width: 1px;
+ border-radius: 5px;
+}
+
+.date-picker .actions,
+.date-picker .nav-bar {
+ display: flex;
+ gap: 0.25rem;
+ padding: 0.25rem;
+ border-bottom: 1px solid #ccc;
+}
+
+.date-picker .nav-bar .title {
+ flex: 1;
+ text-align: center;
+}
+
+.date-picker .actions button,
+.date-picker .nav-bar button {
+ cursor: pointer;
+ font-weight: bold;
+}
+
+.date-picker .calendar {
+ margin: 0.25rem;
+ text-align: center;
+ font-size: 0.9em;
+ border-collapse: collapse;
+}
+
+.date-picker .calendar .day {
+ border-radius: 0.5rem;
+ width: 1.5rem;
+ height: 1.75rem;
+ cursor: pointer;
+}
+
+.date-picker .calendar .day.selected {
+ background: #0a0;
+}
+
+.date-picker .calendar .day.today {
+ background: #88f;
+}
+
+.date-picker .calendar .day.not-in-month {
+ color: #aaa;
+}
+
+.ml-auto {
+ margin-left: auto;
+}