Mobile

LargeTitleBar

An iOS large title that shrinks into the bar as content scrolls, with back and action slots, tied to the scroller without jank.

#mobile#interactive#tablet#navigation#header#scroll

Demo

Preview
Settings

Settings

Airplane mode

Wi-Fi

Bluetooth

Mobile data

Personal hotspot

Notifications

Sounds and haptics

Focus

Screen time

General

Control centre

Display

Wallpaper

Battery

Privacy

Passwords

Language

Storage

Airplane mode

Wi-Fi

Bluetooth

Mobile data

Personal hotspot

Notifications

Sounds and haptics

Focus

Screen time

General

Control centre

Display

Wallpaper

Battery

Privacy

Passwords

Language

Storage

Airplane mode

Wi-Fi

Bluetooth

Mobile data

Personal hotspot

Notifications

Sounds and haptics

Focus

Screen time

General

Control centre

Display

Wallpaper

Battery

Privacy

Passwords

Language

Storage

Back pressed 0, Edit pressed 0

Get it

It needs theme.qwe, as every component does; 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/large_title_bar.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/large_title_bar.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/large_title_bar.qwe
let large_title_bar_demo_rows = ["Airplane mode", "Wi-Fi", "Bluetooth", "Mobile data", "Personal hotspot", "Notifications", "Sounds and haptics",                                 "Focus", "Screen time", "General", "Control centre", "Display", "Wallpaper", "Battery", "Privacy", "Passwords", "Language", "Storage"]component LargeTitleBarDemoLive() {    state backs = 0    state edits = 0    <div.demo-stack>        <div.demo-tablet>            <LargeTitleBar title="Settings" back_text="Home" back_label="Back to Home" on_back={() => { backs += 1 }}                           actions={<button type="button" @click={() => { edits += 1 }}>Edit</button>}>                {for r in range(0, 3) => <div key={r}>                    {for n in large_title_bar_demo_rows => <p key={n} style="margin: 0; padding: 14px 16px; border-bottom: 1px solid var(--ui-border)">{n}</p>}                </div>}            </LargeTitleBar>        </div>        <p class="demo-note">Back pressed {backs}, Edit pressed {edits}</p>    </div>}view LargeTitleBarDemo() = <LargeTitleBarDemoLive/>

Source

ui/large_title_bar.qwe
// LargeTitleBar: an iOS large title. The screen opens with its title large, above the// content; as the content scrolls the title shrinks and fades, and comes to rest small// in the bar, with a back button at one end and actions at the other. Pulled down past// the top on an iPhone it grows a little, as the system's does.////   import ui/large_title_bar.qwe use LargeTitleBar////   component Settings() {//       <LargeTitleBar title="Settings" back_text="Home" on_back={() => { navigate("/") }}//                      actions={<button aria-label="Edit" @click={edit}>Edit</button>}>//           <SettingsList/>//       </LargeTitleBar>//   }//// children: the screen, which scrolls inside the box. title: the heading. on_back():// what the back button does; no button without it. back_text: the words beside its// arrow (none). back_label: its name for screen readers ("Back"). actions: HTML for the// bar's end, such as buttons, which are kept to 44 px each way, so it goes inside one// of your components. scroll_label: the scroller's name for screen readers. The box// takes the size of its parent, which needs a height. On a phone on its side there is// no room for a large title, as on an iPhone: it shows small in the bar from the start.//// There is no jank to have, as the large title is in the scroller: the browser moves it// with the content, off the main thread. All a scroll adds is two numbers on the// box, which the styles here read, and nothing is laid out again.import ui/theme.qwe use ui_clslet large_title_bar_css = css`.ui-largetitle {  position: relative; display: flex; flex-direction: column; width: 100%; height: 100%; min-height: 0; overflow: hidden; container-type: inline-size;  background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.5 var(--ui-font); --ui-largetitle-p: 0; --ui-largetitle-g: 1;}.ui-largetitle-bar {  position: relative; z-index: 1; display: grid; flex: none; grid-template-columns: minmax(44px, 1fr) minmax(0, auto) minmax(44px, 1fr); align-items: center; box-sizing: border-box;  min-height: calc(48px + env(safe-area-inset-top)); padding: env(safe-area-inset-top) max(4px, env(safe-area-inset-right)) 0 max(4px, env(safe-area-inset-left));  border-bottom: 1px solid transparent; background: var(--ui-bg); transition: border-color .15s, background-color .15s;}.ui-largetitle-done .ui-largetitle-bar { border-bottom-color: var(--ui-border); background: color-mix(in srgb, var(--ui-surface) 88%, transparent); }.ui-largetitle-small {  min-width: 0; overflow: hidden; font-size: 17px; font-weight: 600; text-align: center; text-overflow: ellipsis; white-space: nowrap;  opacity: calc((var(--ui-largetitle-p) - .6) * 2.5); transform: translate3d(0, calc((1 - var(--ui-largetitle-p)) * 8px), 0);}.ui-largetitle-start { justify-self: start; min-width: 0; }.ui-largetitle-end { display: flex; justify-self: end; align-items: center; min-width: 0; }.ui-largetitle-back, .ui-largetitle-end button {  display: inline-flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 0 6px; border: 0; border-radius: var(--ui-radius-sm);  background: transparent; color: var(--ui-accent); font: inherit; cursor: pointer;}.ui-largetitle-back { max-width: 100%; padding-inline-start: 2px; }.ui-largetitle-back span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ui-largetitle-back svg { flex: none; width: 26px; height: 26px; }.ui-largetitle-back:dir(rtl) svg { transform: scaleX(-1); }.ui-largetitle-back:focus-visible, .ui-largetitle-end button:focus-visible { outline: none; box-shadow: var(--ui-focus); }@media (hover: hover) { .ui-largetitle-back:hover, .ui-largetitle-end button:hover { background: var(--ui-subtle); } }.ui-largetitle-scroll { flex: 1; min-height: 0; padding-bottom: env(safe-area-inset-bottom); overflow: auto; overscroll-behavior-x: contain; outline: none; }.ui-largetitle-scroll:focus-visible { box-shadow: inset var(--ui-focus); }.ui-largetitle-big {  margin: 0; padding: 2px 16px 10px; font-size: 34px; font-weight: 700; line-height: 1.2; letter-spacing: -.01em; overflow-wrap: anywhere;  transform: scale(calc(var(--ui-largetitle-g) * (1 - var(--ui-largetitle-p) * .25))); transform-origin: 0 100%;  opacity: calc(1 - var(--ui-largetitle-p) * 1.4); will-change: transform, opacity;}.ui-largetitle-big:dir(rtl) { transform-origin: 100% 100%; }@container (min-width: 600px) { .ui-largetitle-big { padding-inline: 24px; font-size: 40px; } .ui-largetitle-bar { padding-inline: 12px; } }@media (max-height: 430px) {  .ui-largetitle-big { display: none; }  .ui-largetitle-small { opacity: 1; transform: none; }}@media (prefers-reduced-motion: reduce) { .ui-largetitle-bar { transition: none; } .ui-largetitle-small { transform: none; } }`// How far the title has shrunk, 0 to 1, over the height it stands in. With no height,// as on a phone on its side, it is small already.fn large_title_bar_share(y: num, reach: num) -> num {    if reach <= 0 { return 1 }    y <= 0 ? 0 : y >= reach ? 1 : y / reach}// How much it grows when pulled past the top, as iOS lets a scroller do: up to a tenth.fn large_title_bar_growth(y: num) -> num {    if y >= 0 { return 1 }    y <= -50 ? 1.1 : 1 - y / 500}component LargeTitleBar(title: str = "", on_back = null, back_text: str? = null, back_label: str = "Back", actions = null,                        scroll_label: str = "Content", class: str? = null, children) {    style global large_title_bar_css    state box = null    state big = null    state reach = 0    state done = false      // the title has come to rest in the bar    let paint = (y: num) => {        let share = large_title_bar_share(y, reach)        box.style.setProperty("--ui-largetitle-p", `${share}`)        box.style.setProperty("--ui-largetitle-g", `${large_title_bar_growth(y)}`)        done = share >= 1    }    <div class={ui_cls(["ui-largetitle", done && "ui-largetitle-done", class])} @mount={(el) => { box = el }}>        <header.ui-largetitle-bar>            <div.ui-largetitle-start>                {on_back != null && <button.ui-largetitle-back type="button" aria-label={back_label} @click={() => { on_back() }}>                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"                         stroke-linejoin="round" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg>                    {back_text != null && <span>{back_text}</span>}                </button>}            </div>            <div.ui-largetitle-small aria-hidden={reach > 0 ? "true" : null}>{title}</div>            <div.ui-largetitle-end>{actions}</div>        </header>        <div.ui-largetitle-scroll tabindex="0" role="region" aria-label={scroll_label}             @mount={(el) => {                 // Rotation takes the large title away and brings it back: its height is the reach.                 // The heading is found here: its own @mount may not have run yet.                 let heading = el.querySelector(".ui-largetitle-big")                 big = heading                 let ro = js_new("ResizeObserver", () => {                     reach = heading.offsetHeight                     paint(el.scrollTop)                 })                 ro.observe(heading)                 () => ro.disconnect()             }}             @scroll={(e) => { paint(e.scrollTop) }}>            <h1.ui-largetitle-big @mount={(el) => { big = el }}>{title}</h1>            {children}        </div>    </div>}
Thread
36Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.