Mobile

DevicePreview

A phone or tablet frame sized by model for showing a component, rotation, safe areas drawn, dark or light.

#mobile#interactive#tablet#preview#frame#rotation

Demo

Preview

iPhone 15, 393 × 852, shown at 100%

Orders
Today, 4 to pack
Tomorrow, 9 booked
Friday, 2 waiting

0 changes.

A tablet on its side, dark, fitted to 300 px.

Get it

It needs theme.qwe, as every component does, and safe_area.qwe; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.

$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/safe_area.qwe$ curl -O https://quillolang.org/components/ui/device_preview.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/device_preview.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/device_preview.qwe
let device_preview_demo_css = css`.device-preview-demo-app { display: grid; grid-template-rows: auto 1fr; height: 100cqh; }.device-preview-demo-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; background: var(--ui-accent); color: var(--ui-accent-fg); font-weight: 600; }.device-preview-demo-cards { display: grid; grid-template-columns: 1fr; gap: 10px; align-content: start; padding: 14px; overflow: auto; }.device-preview-demo-cards > div { padding: 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }@container (min-width: 600px) { .device-preview-demo-cards { grid-template-columns: repeat(3, 1fr); } }.device-preview-demo-count { min-width: 64px; min-height: 44px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 600 16px var(--ui-font); }`component DevicePreviewDemoLive() {    style global device_preview_demo_css    state count = 0    state changes = 0    state said = ""    <div.demo-stack>        <DevicePreview id="device-preview-demo" model="iphone-15" max_height={620}                       on_change={(c) => { changes += 1; said = `${c.model} ${c.landscape} ${c.theme} ${c.insets}` }}>            <SafeArea id="device-preview-demo-safe" class="device-preview-demo-app">                <div.device-preview-demo-head>                    <span>Orders</span>                    <button.device-preview-demo-count id="device-preview-demo-count" type="button" @click={() => { count += 1 }}>{count}</button>                </div>                <div.device-preview-demo-cards id="device-preview-demo-cards">                    <div>Today, 4 to pack</div>                    <div>Tomorrow, 9 booked</div>                    <div>Friday, 2 waiting</div>                </div>            </SafeArea>        </DevicePreview>        <p.demo-note id="device-preview-demo-out">{changes} changes. {said}</p>    </div>}view DevicePreviewDemo() =    <div.demo-stack>        <DevicePreviewDemoLive/>        <DevicePreview id="device-preview-demo-tablet" model="ipad-11" landscape={true} theme="dark" controls={false} max_height={300} label="A tablet">            <p style="margin: 40px">A tablet on its side, dark, fitted to 300 px.</p>        </DevicePreview>    </div>

Source

ui/device_preview.qwe
// DevicePreview: a phone or tablet drawn around whatever you put in it, at the size of a// real model, for showing a component or a screen. It turns on its side, draws the safe// areas, and shows light or dark, and what is inside keeps its state through all of that,// as it is never remade. The screen is a size container and gives its stand-in for// env(), so what is inside lays itself out as on that model: container queries see the// model's width, a SafeArea or PhoneShell inside takes the model's insets, and// position: fixed is held to the screen. Scaled down to fit, never up.////   <DevicePreview model="iphone-15">//       <MyScreen/>//   </DevicePreview>//   <DevicePreview model="ipad-11" landscape={true} theme="dark" controls={false}> ... </DevicePreview>//// model: an id from device_preview_models: "iphone-se", "iphone-15" (default),// "iphone-15-pro-max", "pixel-8", "galaxy-s24", "ipad-mini", "ipad-11", "ipad-13",// "galaxy-tab-s9"; one it does not know gives the first. models: your own list in place// of those, of { id, name, kind, w, h, top, bottom, side, foot, turned_top, radius, cut }:// w and h in points, upright; top and bottom the upright insets; side, foot and// turned_top the insets on its side (left and right, bottom, top); cut "island", "notch",// "punch" or "none".// landscape: on its side. theme: "light" (default) or "dark". insets: draw the safe areas// (true). controls: the bar of model, turn, theme and insets (true). max_height: the most// it may take up, in pixels; by default what is left of the window. label: what screen// readers call the screen ("Device preview"). id, class: for the outer element.// on_change({ model, landscape, theme, insets }): inside one of your components, on a// change made with the controls.//// The iPhone and iPad insets are Apple's. The two Android phones and the Android tablet// are near enough for a preview, as they vary with the system's version and settings.// 100dvh and media queries inside still see the real window: use container units.import ui/theme.qwe use ui_clslet device_preview_css = css`.ui-device-preview { container-type: inline-size; box-sizing: border-box; width: 100%; min-width: 0; color: var(--ui-fg); font: 15px/1.45 var(--ui-font); }.ui-device-preview-layout { display: grid; gap: 12px; }.ui-device-preview-bar { display: flex; flex-wrap: wrap; align-items: end; gap: 8px; min-width: 0; }.ui-device-preview-field { display: grid; gap: 2px; flex: 1 1 150px; min-width: 0; color: var(--ui-muted); font-size: 13px; }.ui-device-preview-field select {  box-sizing: border-box; width: 100%; min-height: 44px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 16px/1.2 var(--ui-font);}.ui-device-preview-field select:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-device-preview-key {  flex: none; min-width: 44px; min-height: 44px; padding: 0 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm);  background: var(--ui-surface); color: var(--ui-fg); font: 500 14px/1 var(--ui-font); cursor: pointer;}.ui-device-preview-key[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-device-preview-key:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-device-preview-size { flex: 1 1 100%; margin: 0; color: var(--ui-muted); font-size: 13px; font-variant-numeric: tabular-nums; }.ui-device-preview-stage { min-width: 0; overflow: hidden; }.ui-device-preview-fit { margin: 0 auto; transition: width .24s ease, height .24s ease; }.ui-device-preview-body {  box-sizing: border-box; padding: 10px; border-radius: var(--ui-device-preview-outer); background: #16151c; box-shadow: var(--ui-shadow-lg), inset 0 0 0 2px #3b3946;  transform: scale(var(--ui-device-preview-scale)); transform-origin: 0 0; transition: transform .24s ease, width .24s ease, height .24s ease;}.ui-device-preview-screen { position: relative; container-type: size; overflow: hidden; border-radius: var(--ui-device-preview-radius); background: var(--ui-bg); color: var(--ui-fg); }.ui-device-preview-marks { position: absolute; inset: 0; z-index: 50; overflow: hidden; border-radius: inherit; pointer-events: none; }.ui-device-preview-band {  position: absolute; display: grid; place-items: center; overflow: hidden; color: #fff; font: 600 11px/1 var(--ui-mono);  background: repeating-linear-gradient(45deg, rgba(220, 40, 60, .38) 0 6px, rgba(220, 40, 60, .16) 6px 12px);}.ui-device-preview-cut { position: absolute; background: #000; }.ui-device-preview-cut-island { left: 50%; top: 11px; width: 126px; height: 37px; margin-left: -63px; border-radius: 19px; }.ui-device-preview-cut-notch { left: 50%; top: 0; width: 160px; height: 30px; margin-left: -80px; border-radius: 0 0 18px 18px; }.ui-device-preview-cut-punch { left: 50%; top: 12px; width: 20px; height: 20px; margin-left: -10px; border-radius: 50%; }.ui-device-preview-turned .ui-device-preview-cut-island { left: 11px; top: 50%; width: 37px; height: 126px; margin: -63px 0 0; }.ui-device-preview-turned .ui-device-preview-cut-notch { left: 0; top: 50%; width: 30px; height: 160px; margin: -80px 0 0; border-radius: 0 18px 18px 0; }.ui-device-preview-turned .ui-device-preview-cut-punch { left: 12px; top: 50%; margin: -10px 0 0; }.ui-device-preview-home { position: absolute; left: 50%; bottom: 7px; width: 134px; height: 5px; margin-left: -67px; border-radius: 3px; background: color-mix(in srgb, var(--ui-fg) 70%, transparent); }@container (min-width: 700px) {  .ui-device-preview-layout { grid-template-columns: 200px minmax(0, 1fr); align-items: start; }  .ui-device-preview-bar { flex-direction: column; align-items: stretch; }  .ui-device-preview-field { flex: none; }  .ui-device-preview-size { flex: none; }}@media (prefers-reduced-motion: reduce) { .ui-device-preview-fit, .ui-device-preview-body { transition: none; } }`let device_preview_models = [    { id: "iphone-se", name: "iPhone SE", kind: "phone", w: 375, h: 667, top: 20, bottom: 0, side: 0, foot: 0, turned_top: 0, radius: 0, cut: "none" },    { id: "iphone-15", name: "iPhone 15", kind: "phone", w: 393, h: 852, top: 59, bottom: 34, side: 59, foot: 21, turned_top: 0, radius: 48, cut: "island" },    { id: "iphone-15-pro-max", name: "iPhone 15 Pro Max", kind: "phone", w: 430, h: 932, top: 59, bottom: 34, side: 59, foot: 21, turned_top: 0, radius: 52, cut: "island" },    { id: "pixel-8", name: "Pixel 8", kind: "phone", w: 412, h: 915, top: 48, bottom: 24, side: 48, foot: 24, turned_top: 0, radius: 36, cut: "punch" },    { id: "galaxy-s24", name: "Galaxy S24", kind: "phone", w: 360, h: 780, top: 40, bottom: 24, side: 40, foot: 24, turned_top: 0, radius: 30, cut: "punch" },    { id: "ipad-mini", name: "iPad mini", kind: "tablet", w: 744, h: 1133, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 20, cut: "none" },    { id: "ipad-11", name: "iPad Pro 11", kind: "tablet", w: 834, h: 1194, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 18, cut: "none" },    { id: "ipad-13", name: "iPad Pro 13", kind: "tablet", w: 1024, h: 1366, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 18, cut: "none" },    { id: "galaxy-tab-s9", name: "Galaxy Tab S9", kind: "tablet", w: 753, h: 1205, top: 24, bottom: 20, side: 0, foot: 20, turned_top: 24, radius: 14, cut: "none" },]fn device_preview_number(x, fallback) {    let kind = type_of(x)    (kind == "int" || kind == "float") && x >= 0 ? x : fallback}fn device_preview_find(id, models) {    let known = type_of(models) == "list" && len(models) > 0 ? models : device_preview_models    find(known, (m) => get(m, "id") == id) ?? known[0]}// The screen's size in points, as it stands.fn device_preview_size(m, turned) {    let w = device_preview_number(get(m, "w"), 390)    let h = device_preview_number(get(m, "h"), 844)    turned == true ? { w: h, h: w } : { w, h }}// The insets as they stand: upright they are top and bottom, on its side the notch moves to a side.fn device_preview_insets(m, turned) {    let n = (k: str) => device_preview_number(get(m, k), 0)    turned == true        ? { top: n("turned_top"), right: n("side"), bottom: n("foot"), left: n("side") }        : { top: n("top"), right: 0, bottom: n("bottom"), left: 0 }}// How far to scale a screen of w by h, with its bezel, to fit: never above 1. With no room// measured yet, 1.fn device_preview_scale(room_w, room_h, w, h, bezel) {    if room_w <= 0 || room_h <= 0 { return 1 }    floor(min([1, room_w / (w + 2 * bezel), room_h / (h + 2 * bezel)]) * 1000) / 1000}let device_preview_bezel = 10fn device_preview_percent(scale) -> str = `${round(scale * 100)}%`component DevicePreview(model: str = "iphone-15", models = null, landscape: bool = false, theme: str = "light", insets: bool = true,                        controls: bool = true, max_height = null, label: str = "Device preview", id: str? = null, class: str? = null,                        on_change = null, children) {    style global device_preview_css    state pick = model    state turned = landscape    state dark = theme == "dark"    state marks = insets    state room_w = 0    state room_h = 0    let known = type_of(models) == "list" && len(models) > 0 ? models : device_preview_models    let m = device_preview_find(pick, models)    let size = device_preview_size(m, turned)    let safe = device_preview_insets(m, turned)    let scale = device_preview_scale(room_w, room_h, size.w, size.h, device_preview_bezel)    let whole_w = size.w + 2 * device_preview_bezel    let whole_h = size.h + 2 * device_preview_bezel    let radius = device_preview_number(get(m, "radius"), 0)    let cut = get(m, "cut") ?? "none"    let tell = () => { on_change && on_change({ model: m.id, landscape: turned, theme: dark ? "dark" : "light", insets: marks }) }    <div class={ui_cls(["ui-device-preview", turned && "ui-device-preview-turned", class])} id={id}>        <div.ui-device-preview-layout>            {controls && <div.ui-device-preview-bar role="group" aria-label="Preview settings">                <label.ui-device-preview-field>Model                    <select name="model" @change={(e) => { pick = e.value; tell() }}>                        {for x in known => <option key={x.id} value={x.id} selected={x.id == m.id}>{x.name}</option>}                    </select>                </label>                <button.ui-device-preview-key type="button" aria-pressed={turned ? "true" : "false"} @click={() => { turned = !turned; tell() }}>Turn</button>                <button.ui-device-preview-key type="button" aria-pressed={dark ? "true" : "false"} @click={() => { dark = !dark; tell() }}>Dark</button>                <button.ui-device-preview-key type="button" aria-pressed={marks ? "true" : "false"} @click={() => { marks = !marks; tell() }}>Safe areas</button>                <p.ui-device-preview-size role="status">{m.name}, {size.w} × {size.h}, shown at {device_preview_percent(scale)}</p>            </div>}            <div.ui-device-preview-stage                 @mount={(el) => {                     let win = js("window")                     let fit = () => {                         room_w = el.clientWidth                         room_h = max_height != null ? max_height : win.innerHeight - el.getBoundingClientRect().top + win.document.documentElement.getBoundingClientRect().top - 12                     }                     fit()                     let grown = js_new("ResizeObserver", fit)                     grown.observe(el)                     win.addEventListener("resize", fit)                     () => {                         grown.disconnect()                         win.removeEventListener("resize", fit)                     }                 }}>                <div.ui-device-preview-fit style={`width: ${whole_w * scale}px; height: ${whole_h * scale}px`}>                    <div.ui-device-preview-body style={`width: ${whole_w}px; height: ${whole_h}px; --ui-device-preview-scale: ${scale}; --ui-device-preview-outer: ${radius + device_preview_bezel}px; --ui-device-preview-radius: ${radius}px`}>                        // data-small-target: what is inside is drawn at the model's size and scaled to fit, so a 44 px button there is a picture of one.                        <div class={ui_cls(["ui-device-preview-screen", dark ? "ui-dark" : "ui-light"])} role="group" aria-label={label} data-kind={get(m, "kind") ?? "phone"} data-small-target                             style={`width: ${size.w}px; height: ${size.h}px; --ui-safe-top: ${safe.top}px; --ui-safe-right: ${safe.right}px; --ui-safe-bottom: ${safe.bottom}px; --ui-safe-left: ${safe.left}px`}>                            {children}                            <div.ui-device-preview-marks aria-hidden="true">                                {cut != "none" && <i class={`ui-device-preview-cut ui-device-preview-cut-${cut}`}></i>}                                {marks && <div>                                    <span.ui-device-preview-band style={`left: 0; right: 0; top: 0; height: ${safe.top}px`}>{safe.top > 0 ? safe.top : ""}</span>                                    <span.ui-device-preview-band style={`left: 0; right: 0; bottom: 0; height: ${safe.bottom}px`}>{safe.bottom > 0 ? safe.bottom : ""}</span>                                    <span.ui-device-preview-band style={`left: 0; top: 0; bottom: 0; width: ${safe.left}px`}>{safe.left > 0 ? safe.left : ""}</span>                                    <span.ui-device-preview-band style={`right: 0; top: 0; bottom: 0; width: ${safe.right}px`}>{safe.right > 0 ? safe.right : ""}</span>                                </div>}                                {safe.bottom > 16 && <i.ui-device-preview-home></i>}                            </div>                        </div>                    </div>                </div>            </div>        </div>    </div>}
Thread
25Useful? Like it, or add to its thread below.

Thread 0 posts

On DevicePreview: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.