LargeTitleBar
An iOS large title that shrinks into the bar as content scrolls, with back and action slots, tied to the scroller without jank.
Demo
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.qweimport 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.
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
// 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>}Related
Thread 0 posts
On LargeTitleBar: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.