diff --git a/spec/pwa_install_component_spec.cr b/spec/pwa_install_component_spec.cr
new file mode 100644
index 0000000..486037c
--- /dev/null
+++ b/spec/pwa_install_component_spec.cr
@@ -0,0 +1,35 @@
+require "./spec_helper"
+
+module Crumble::PwaInstallComponentSpec
+ class LayoutWithInstall < ToHtml::Layout
+ ToHtml.instance_template do
+ super do
+ Crumble::PwaInstallComponent.new(ctx: ctx).to_html
+ end
+ end
+
+ def window_title
+ "Pwa Install Component Spec"
+ end
+ end
+end
+
+describe Crumble::PwaInstallComponent do
+ it "renders as a drop-in layout component via #to_html" do
+ html = Crumble::PwaInstallComponentSpec::LayoutWithInstall.new(ctx: test_handler_context).to_html
+ style_asset = AssetFileRegistry.query(Crumble::PwaInstallComponent::Style.uri_path).not_nil!
+
+ html.should contain(%())
+ html.should contain(%(
))
+ html.should contain(%(
))
+ html.should contain(%(
))
+ html.should contain(%())
+ html.should contain("To install this app, tap Share, then Add to Home Screen.")
+ style_asset.contents.should contain(".#{Crumble::PwaInstallComponent::InstallContainer} {")
+ style_asset.contents.should contain("background: #f6edac;")
+ style_asset.contents.should contain(".#{Crumble::PwaInstallComponent::InstallTrigger} {")
+ style_asset.contents.should contain("background: #757575;")
+ style_asset.contents.should contain(".#{Crumble::PwaInstallComponent::Hidden} {")
+ style_asset.contents.should contain("display: none;")
+ end
+end
diff --git a/src/crumble.cr b/src/crumble.cr
index 233b4f6..68375b5 100644
--- a/src/crumble.cr
+++ b/src/crumble.cr
@@ -12,3 +12,4 @@ require "./server/file_session_store"
require "./server"
require "./form"
require "./pwa_utils"
+require "./pwa_install_component"
diff --git a/src/pwa_install_component.cr b/src/pwa_install_component.cr
new file mode 100644
index 0000000..d169b25
--- /dev/null
+++ b/src/pwa_install_component.cr
@@ -0,0 +1,193 @@
+require "js"
+require "to_html"
+
+module Crumble
+ class PwaInstallComponent
+ include Crumble::ContextView
+ css_class InstallContainer
+ css_class InstallTrigger
+ css_class InstallPanel
+ css_class InstallPanelDialog
+ css_class InstallPanelText
+ css_class InstallPanelClose
+ css_class Hidden
+
+ style Style do
+ rule InstallContainer do
+ position :fixed
+ left 0
+ right 0
+ bottom 0
+ width 100.vw
+ box_sizing :border_box
+ padding 0.5.rem, 0.75.rem
+ background "#f6edac"
+ display :flex
+ justify_content :center
+ align_items :center
+ z_index 2147483640
+ end
+
+ rule InstallTrigger do
+ appearance "none"
+ border 0
+ border_radius 999.px
+ padding 0.45.rem, 1.rem
+ background "#757575"
+ color "#ffffff"
+ font_size 0.9.rem
+ line_height 1
+ end
+
+ rule InstallPanel do
+ position :fixed
+ inset 0
+ display :flex
+ justify_content :center
+ align_items :flex_end
+ padding 0, 0.75.rem, 4.rem
+ box_sizing :border_box
+ background rgb(0, 0, 0, alpha: 30.percent)
+ z_index 2147483641
+ end
+
+ rule InstallPanelDialog do
+ width 100.percent
+ max_width 30.rem
+ box_sizing :border_box
+ border_radius 0.75.rem
+ padding 0.8.rem
+ background "#ffffff"
+ color "#1f2937"
+ box_shadow 0.px, 0.5.rem, 1.5.rem, rgb(0, 0, 0, alpha: 20.percent)
+ end
+
+ rule InstallPanelText do
+ margin 0, 0, 0.6.rem
+ font_size 0.9.rem
+ end
+
+ rule InstallPanelClose do
+ appearance "none"
+ border 0
+ border_radius 0.5.rem
+ padding 0.4.rem, 0.75.rem
+ background "#757575"
+ color "#ffffff"
+ font_size 0.85.rem
+ end
+
+ rule Hidden do
+ display :none
+ end
+
+ media(min_width 901.px) do
+ rule InstallContainer, InstallPanel do
+ display :none
+ end
+ end
+ end
+
+ class Script < JS::Code
+ def_to_js do
+ root = document.querySelector(InstallContainer.to_css_selector.to_s.to_js_ref)
+ button = document.querySelector(InstallTrigger.to_css_selector.to_s.to_js_ref)
+ panel = document.querySelector(InstallPanel.to_css_selector.to_s.to_js_ref)
+ close_button = document.querySelector(InstallPanelClose.to_css_selector.to_s.to_js_ref)
+
+ if root && button && panel && close_button
+ deferred_prompt = nil
+ user_agent = navigator.userAgent
+ mobile_media = window.matchMedia("(max-width: 900px)")
+ is_mobile_agent = user_agent.includes("Android") || user_agent.includes("iPhone") || user_agent.includes("iPad") || user_agent.includes("iPod") || user_agent.includes("IEMobile") || user_agent.includes("Opera Mini")
+ is_mobile = mobile_media.matches || is_mobile_agent
+
+ # iPadOS can report MacIntel, so we include a touch-point check.
+ is_ios = user_agent.includes("iPhone") || user_agent.includes("iPad") || user_agent.includes("iPod") || (navigator.platform == "MacIntel" && navigator.maxTouchPoints > 1)
+ is_safari = user_agent.includes("Safari") && user_agent.includes("CriOS") == false && user_agent.includes("FxiOS") == false && user_agent.includes("EdgiOS") == false && user_agent.includes("OPiOS") == false && user_agent.includes("Chrome") == false && user_agent.includes("Android") == false
+ is_ios_safari = is_ios && is_safari
+ is_standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone == true
+ hidden_class = Hidden.to_s.to_js_ref
+
+ close_panel = -> { panel.classList.add(hidden_class) }
+ open_panel = -> { panel.classList.remove(hidden_class) }
+ hide_install_ui = -> { root.classList.add(hidden_class); close_panel._call }
+ show_install_ui = -> { root.classList.remove(hidden_class) }
+
+ if is_mobile == false || is_standalone
+ hide_install_ui._call
+ else
+ if is_ios_safari
+ show_install_ui._call
+ end
+
+ # Capture browser install intent and defer native prompt to custom CTA click.
+ window.addEventListener("beforeinstallprompt", ->(event) do
+ event.preventDefault._call
+ deferred_prompt = event
+ if window.matchMedia("(display-mode: standalone)").matches == false && navigator.standalone != true
+ show_install_ui._call
+ end
+ end)
+
+ window.addEventListener("appinstalled", -> do
+ deferred_prompt = nil
+ hide_install_ui._call
+ end)
+
+ close_button.addEventListener("click", -> do
+ close_panel._call
+ end)
+
+ # Treat clicks on the backdrop (outside the panel card) as dismiss.
+ panel.addEventListener("click", ->(event) do
+ if event.target == panel
+ close_panel._call
+ end
+ end)
+
+ button.addEventListener("click", async do
+ is_standalone_now = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone == true
+
+ if is_standalone_now
+ hide_install_ui._call
+ elsif is_ios_safari
+ close_panel._call
+ open_panel._call
+ elsif deferred_prompt
+ deferred_prompt.prompt._call
+ choice_result = await(deferred_prompt.userChoice)
+ deferred_prompt = nil
+
+ if choice_result && (choice_result.outcome == "accepted" || choice_result.outcome == "dismissed")
+ hide_install_ui._call
+ end
+ end
+ end)
+ end
+ end
+ end
+ end
+
+ template do
+ div InstallContainer, Hidden do
+ button InstallTrigger, type: "button", aria: {haspopup: "dialog"} do
+ "Install app"
+ end
+ end
+
+ div InstallPanel, Hidden do
+ div InstallPanelDialog, role: "dialog", aria: {modal: true} do
+ p InstallPanelText do
+ "To install this app, tap Share, then Add to Home Screen."
+ end
+ button InstallPanelClose, type: "button", aria: {label: "Close install instructions"} do
+ "Close"
+ end
+ end
+ end
+
+ Script
+ end
+ end
+end