Mobile

ProfileScreen

A cover collapsing as you scroll, with stats and sticky tabs over one shared scroller. Two columns on a tablet.

#mobile#interactive#profile#tabs#scroll#tablet

Demo

Preview

Ana Silva

@ana

Site engineer on the Mill Road bridge. Photographs mostly concrete.

Posts
214
Followers
15.3K
Following
1.9K
Joined
2019

Post 1: the north side, week 1

Post 2: the north side, week 2

Post 3: the north side, week 3

Post 4: the north side, week 4

Post 5: the north side, week 5

Post 6: the north side, week 6

Post 7: the north side, week 7

Post 8: the north side, week 8

Post 9: the north side, week 9

Post 10: the north side, week 10

Post 11: the north side, week 11

Post 12: the north side, week 12

Post 13: the north side, week 13

Post 14: the north side, week 14

Post 15: the north side, week 15

Post 16: the north side, week 16

Post 17: the north side, week 17

Post 18: the north side, week 18

Post 19: the north side, week 19

Post 20: the north side, week 20

Post 21: the north side, week 21

Post 22: the north side, week 22

Post 23: the north side, week 23

Post 24: the north side, week 24

Post 25: the north side, week 25

Post 26: the north side, week 26

Post 27: the north side, week 27

Post 28: the north side, week 28

Post 29: the north side, week 29

Post 30: the north side, week 30

Liked 1

Liked 2

Liked 3

Liked 4

Liked 5

Liked 6

Liked 7

Liked 8

Liked 9

Liked 10

Liked 11

Liked 12

Liked 13

Liked 14

Liked 15

Liked 16

Liked 17

Liked 18

Liked 19

Liked 20

Liked 21

Liked 22

Liked 23

Liked 24

Short, so that the tabs must hold their place by themselves.

Nothing yet.

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/profile_screen.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/profile_screen.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/profile_screen.qwe
let profile_screen_demo_posts = for i in range(30) => `Post ${i + 1}: the north side, week ${i + 1}`let profile_screen_demo_likes = for i in range(24) => `Liked ${i + 1}`let profile_screen_demo_cover = "data:image/svg+xml," + url_encode(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 390 180"><rect width="390" height="180" fill="#2f6f9f"/><path d="M0 150 C100 80 290 80 390 150 V180 H0z" fill="#23506f"/></svg>`)component ProfileScreenDemoLive() {    state note = "Nothing yet."    state tall = false    <div.demo-stack>        <div.demo-phone id="profile-screen-demo" style="height: 620px">            <ProfileScreen name="Ana Silva" handle="@ana" cover={profile_screen_demo_cover} cover_height={tall ? 200 : 180} class={tall ? "demo-tall" : "demo-short"} bio="Site engineer on the Mill Road bridge. Photographs mostly concrete." action="Follow"                           stats={[{ label: "Posts", value: 214 }, { label: "Followers", value: 15300 }, { label: "Following", value: 1999 }, { label: "Joined", value: "2019" }]}                           tabs={[{ id: "posts", label: "Posts", count: 214 }, { id: "likes", label: "Likes", count: 1200 }, { id: "about", label: "About" }]}                           on_action={(on) => { note = `Following: ${on}` }} on_tab={(t) => { note = `Tab: ${t}` }} on_back={() => { note = "Back" }}>                <div data-tab="posts">{for p in profile_screen_demo_posts => <p key={p}>{p}</p>}</div>                <div data-tab="likes">{for p in profile_screen_demo_likes => <p key={p}>{p}</p>}</div>                <div data-tab="about"><p>Short, so that the tabs must hold their place by themselves.</p></div>            </ProfileScreen>        </div>        <Button size="lg" variant="outline" id="profile-screen-tall" on_click={() => { tall = !tall }}>Change the cover's height</Button>        <p.demo-note id="profile-screen-note">{note}</p>    </div>}view ProfileScreenDemo() = <ProfileScreenDemoLive/>

Source

ui/profile_screen.qwe
// ProfileScreen: a person's page in a phone app. One scroller holds it all: the cover// shrinks to a bar as you scroll and stays as that bar, the name appears in it, the// picture shrinks away, and the tabs stick under it. Each tab remembers how far down it// was, so changing tabs with the page scrolled does not lose your place, and the header// never jumps. On a tablet the person's side stays in a column of its own, beside// the tabs and what they show.////   <ProfileScreen name="Ana Silva" handle="@ana" bio="Site engineer." cover="/c/ana.jpg" avatar="/a/ana.jpg"//                  stats={[{ label: "Posts", value: 214 }, { label: "Followers", value: 15300 }]}//                  tabs={[{ id: "posts", label: "Posts" }, { id: "likes", label: "Likes", count: 41 }]}>//       <div data-tab="posts"> ... </div>//       <div data-tab="likes"> ... </div>//   </ProfileScreen>//// name, handle, bio, avatar: who. cover: a picture's URL, or a colour from the theme.// cover_height: how tall it is, in pixels (180). stats: a list of { label, value }; a large// number is shortened, 15300 to 15.3K, never upwards; text is shown as it is.// tabs: a list of { id, label, count? }. start: the id of the first shown (the first// tab). children: a <div data-tab="id"> for each tab, shown when its tab is: without// JavaScript the first shows. action: the text of a button by the name, such as "Follow";// action_on: its text once pressed ("Following"); active: pressed to start with.// on_action(on), on_tab(id), on_back(), which adds a Back button: inside one of your components.// id, class: for the outer element. It fills its parent, which needs a height.import ui/theme.qwe use ui_clslet profile_screen_css = css`.ui-profile-screen { position: relative; container-type: size; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; overflow: hidden; background: var(--ui-bg); color: var(--ui-fg); font: 15px/1.45 var(--ui-font); --ui-profile-screen-bar: calc(52px + env(safe-area-inset-top)); }.ui-profile-screen-scroll { box-sizing: border-box; width: 100%; height: 100%; overflow-y: auto; overflow-anchor: none; overscroll-behavior-y: contain; }.ui-profile-screen-scroll:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }/* On a phone this column is no box at all, so that the cover can stick for the whole page. */.ui-profile-screen-aside { display: contents; }/* What scrolling changes by hand is kept here, whose attributes are fixed: the outer element's are written again whenever a prop they read changes. */.ui-profile-screen-in { position: relative; width: 100%; height: 100%; }.ui-profile-screen-bar { position: absolute; top: 0; left: 0; right: 0; z-index: 4; display: flex; align-items: center; gap: 4px; box-sizing: border-box; height: var(--ui-profile-screen-bar); padding: env(safe-area-inset-top) 12px 0 4px; color: #fff; pointer-events: none; }.ui-profile-screen-bar button { pointer-events: auto; }.ui-profile-screen-back { flex: none; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 50%; background: rgba(15, 14, 24, .35); color: #fff; cursor: pointer; }.ui-profile-screen-back svg { width: 22px; height: 22px; }.ui-profile-screen-back:focus-visible { outline: 2px solid #fff; }.ui-profile-screen-title { min-width: 0; padding-left: 10px; overflow: hidden; font-size: 17px; font-weight: 600; text-overflow: ellipsis; text-shadow: 0 1px 4px rgba(0, 0, 0, .5); white-space: nowrap; opacity: 0; transform: translateY(6px); transition: opacity .16s, transform .16s; }.ui-profile-screen-titled .ui-profile-screen-title { opacity: 1; transform: none; }.ui-profile-screen-cover {  position: sticky; top: calc(var(--ui-profile-screen-bar) - var(--ui-profile-screen-h)); z-index: 3; height: var(--ui-profile-screen-h); overflow: hidden;  background: linear-gradient(135deg, var(--ui-accent), color-mix(in srgb, var(--ui-accent) 55%, #000));}.ui-profile-screen-cover img { display: block; width: 100%; height: 100%; object-fit: cover; transform-origin: 50% 100%; }.ui-profile-screen-cover::after { content: ""; position: absolute; inset: 0; background: rgba(15, 14, 24, calc(.5 * var(--ui-profile-screen-p, 0))); }.ui-profile-screen-who { position: relative; padding: 0 16px 12px; }.ui-profile-screen-face {  position: relative; z-index: 3; display: grid; place-items: center; box-sizing: border-box; width: 84px; height: 84px; margin-top: -42px; overflow: hidden; border: 3px solid var(--ui-bg); border-radius: 50%;  background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 28px; font-weight: 650; transform: scale(calc(1 - .45 * var(--ui-profile-screen-p, 0))); transform-origin: 0 100%;}.ui-profile-screen-face img { width: 100%; height: 100%; object-fit: cover; }.ui-profile-screen-line { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-top: 6px; }.ui-profile-screen-names { min-width: 0; }.ui-profile-screen-who h2 { margin: 0; font-size: 22px; line-height: 1.25; overflow-wrap: anywhere; }.ui-profile-screen-handle { margin: 0; color: var(--ui-muted); overflow-wrap: anywhere; }.ui-profile-screen-bio { margin: 8px 0 0; overflow-wrap: anywhere; }.ui-profile-screen-action { flex: none; min-height: 44px; padding: 0 18px; border: 1px solid transparent; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px var(--ui-font); cursor: pointer; }.ui-profile-screen-action[aria-pressed="true"] { border-color: var(--ui-border); background: transparent; color: var(--ui-fg); }.ui-profile-screen-action:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-profile-screen-stats { display: flex; gap: 20px; margin: 10px 0 0; padding: 0; }.ui-profile-screen-stats div { display: flex; flex-direction: column-reverse; min-width: 0; }.ui-profile-screen-stats dt { color: var(--ui-muted); font-size: 13px; }.ui-profile-screen-stats dd { margin: 0; font-size: 17px; font-weight: 650; font-variant-numeric: tabular-nums; }.ui-profile-screen-tabs {  position: sticky; top: var(--ui-profile-screen-bar); z-index: 2; display: flex; overflow-x: auto; border-bottom: 1px solid var(--ui-border); background: var(--ui-bg); overscroll-behavior-x: contain; scrollbar-width: none;}.ui-profile-screen-tabs::-webkit-scrollbar { display: none; }.ui-profile-screen-tab { flex: 1 0 auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 88px; min-height: 48px; padding: 0 14px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--ui-muted); font: 600 14px var(--ui-font); cursor: pointer; }.ui-profile-screen-tab[aria-selected="true"] { border-bottom-color: var(--ui-accent); color: var(--ui-fg); }.ui-profile-screen-tab:focus-visible { outline: none; box-shadow: inset var(--ui-focus); }.ui-profile-screen-tab small { color: var(--ui-muted); font-size: 12px; font-weight: 500; font-variant-numeric: tabular-nums; }/* Tall enough that a short tab can keep the tabs at the top. */.ui-profile-screen-panels { min-height: calc(100cqh - 100px); padding: 12px 16px calc(16px + env(safe-area-inset-bottom)); }.ui-profile-screen-panels:not([data-ready]) > :not(:first-child) { display: none; }.ui-profile-screen-panels > [hidden] { display: none; }/* A tablet keeps the person in sight: their column stays, and only the tabs' side scrolls. */@container (min-width: 720px) {  .ui-profile-screen-layout { display: grid; grid-template-columns: 320px minmax(0, 1fr); align-items: start; }  .ui-profile-screen-aside { display: block; position: sticky; top: 0; max-height: 100cqh; overflow-y: auto; border-right: 1px solid var(--ui-border); }  .ui-profile-screen-cover { position: relative; top: 0; }  .ui-profile-screen-face { transform: none; }  .ui-profile-screen-tabs { top: 0; }  .ui-profile-screen-panels { min-height: calc(100cqh - 50px); }  .ui-profile-screen-cover::after { display: none; }}@media (prefers-reduced-motion: reduce) { .ui-profile-screen-title { transition: none; } }`// 999, 1.2K, 15.3K, 1.2M: cut down, never up, so a page never claims more than is true.// What is not a number stays as it is.fn profile_screen_count(value) -> str {    if type_of(value) != "int" && type_of(value) != "float" { return str(value ?? "") }    let n = floor(abs(value))    let sign = value < 0 ? "-" : ""    if n < 1000 { return `${sign}${n}` }    let short = (over: int, unit: str) => {        let tenths = floor(n * 10 / over)        let whole = floor(tenths / 10)        let rest = tenths - whole * 10        rest > 0 && whole < 100 ? `${sign}${whole}.${rest}${unit}` : `${sign}${whole}${unit}`    }    if n < 1000000 { return short(1000, "K") }    if n < 1000000000 { return short(1000000, "M") }    short(1000000000, "B")}// How far the cover has collapsed: 0 to 1.fn profile_screen_progress(top: num, reach: num) -> num {    if reach <= 0 { return 0 }    round(max([0, min([top / reach, 1])]) * 1000) / 1000}// How much a cover grows when iOS pulls past the top, whose scroll goes negative.fn profile_screen_stretch(top: num, height: num) -> num {    if top >= 0 || height <= 0 { return 1 }    round((1 + (0 - top) / height) * 1000) / 1000}// Where to scroll on arriving at a tab. With the tabs not yet stuck, nothing moves. With them// stuck, it goes where that tab was left, but never so high as to bring the header back.fn profile_screen_restore(now_at: num, saved, pin: num) -> num {    if now_at < pin { return now_at }    max([saved ?? pin, pin])}// Up to two letters for someone with no picture.fn profile_screen_initials(name) -> str =    upper(join(split(trim(str(name ?? "")), " ") | filter((w) => w != "") | map((w) => slice(w, 0, 1)) | slice(0, 2), ""))component ProfileScreen(name: str = "", handle: str? = null, bio: str? = null, avatar: str? = null, cover: str? = null, cover_height: int = 180,                        stats: list = [], tabs: list = [], start: str? = null, action: str? = null, action_on: str = "Following", active: bool = false,                        on_action = null, on_tab = null, on_back = null, id: str? = null, class: str? = null, children) {    style global profile_screen_css    state root = null    state inner = null    state box = null    state strip = null    state panels = null    state pic = null    state bar = null    state tab = start ?? (len(tabs) > 0 ? tabs[0].id : "")    state saved = {}    state on = active    let wide = () => root.clientWidth >= 720    // The bar's height has the safe area in it, so it is measured, not assumed.    let stop = () => wide() ? 0 : bar.offsetHeight    let reach = () => max([cover_height - bar.offsetHeight, 1])    let paint = () => {        let top = box.scrollTop        let p = wide() ? 0 : profile_screen_progress(top, reach())        inner.style.setProperty("--ui-profile-screen-p", str(p))        inner.classList.toggle("ui-profile-screen-titled", !wide() && top >= reach() + 40)        if pic != null { pic.style.transform = top < 0 ? `scale(${profile_screen_stretch(top, cover_height)})` : "" }    }    let show = (want: str) => {        for c in js("Array.from", panels.children) {            c.hidden = c.dataset.tab != want            c.setAttribute("role", "tabpanel")        }    }    let pick = (want: str, focus: bool) => {        if want != tab {            let was = box.scrollTop            // The scroll at which the tabs reach their sticky place, from what does not stick.            let line = panels.getBoundingClientRect().top - box.getBoundingClientRect().top + was - strip.offsetHeight - stop()            saved = set(saved, tab, was)            tab = want            show(want)            box.scrollTop = profile_screen_restore(was, get(saved, want, null), line)            paint()            on_tab && on_tab(want)        }        let b = find(js("Array.from", strip.querySelectorAll("button")), (x) => x.dataset.tab == want)        if b != null {            strip.scrollLeft = b.offsetLeft - (strip.clientWidth - b.offsetWidth) / 2            if focus { b.focus() }        }    }    <div class={ui_cls(["ui-profile-screen", class])} id={id} style={`--ui-profile-screen-h: ${cover_height}px`}         @mount={(el) => {             root = el             () => { root = null }         }}>        <div.ui-profile-screen-in             @mount={(el) => {                 let sized = js_new("ResizeObserver", (_r = null) => { if root != null && box != null { paint() } })                 inner = el                 sized.observe(el)                 () => { sized.disconnect() }             }}>            <div.ui-profile-screen-bar @mount={(el) => { bar = el }}>                {on_back != null && <button.ui-profile-screen-back type="button" aria-label="Back" @click={() => { on_back() }}>                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg>                </button>}                <span.ui-profile-screen-title aria-hidden="true">{name}</span>            </div>            <div.ui-profile-screen-scroll tabindex="0" @mount={(el) => { box = el }} @scroll={paint}>                <div.ui-profile-screen-layout>                    <div.ui-profile-screen-aside>                        <div.ui-profile-screen-cover>                            {cover != null && <img src={cover} alt="" @mount={(el) => { pic = el }}/>}                        </div>                        <div.ui-profile-screen-who>                            <div.ui-profile-screen-face aria-hidden="true">{avatar != null ? <img src={avatar} alt=""/> : profile_screen_initials(name)}</div>                            <div.ui-profile-screen-line>                                <div.ui-profile-screen-names>                                    <h2>{name}</h2>                                    {handle != null && <p.ui-profile-screen-handle>{handle}</p>}                                </div>                                {action != null && <button.ui-profile-screen-action type="button" aria-pressed={on ? "true" : "false"}                                        @click={() => {                                            on = !on                                            on_action && on_action(on)                                        }}>{on ? action_on : action}</button>}                            </div>                            {bio != null && <p.ui-profile-screen-bio>{bio}</p>}                            {len(stats) > 0 && <dl.ui-profile-screen-stats>                                {for s in stats => <div key={s.label}><dt>{s.label}</dt><dd>{profile_screen_count(s.value)}</dd></div>}                            </dl>}                        </div>                    </div>                    <div.ui-profile-screen-main>                        {len(tabs) > 0 && <div.ui-profile-screen-tabs role="tablist" aria-label={`${name}'s pages`} @mount={(el) => { strip = el }}                             @keydown={(e) => {                                 let ids = tabs | map((t) => t.id)                                 let at = index_of(ids, tab) ?? 0                                 let n = len(ids)                                 let to = match e.key {                                     "ArrowRight" => (at + 1) % n                                     "ArrowLeft" => (at + n - 1) % n                                     "Home" => 0                                     "End" => n - 1                                     _ => -1                                 }                                 if to >= 0 {                                     e.preventDefault()                                     pick(ids[to], true)                                 }                             }}>                            {for t in tabs => <button.ui-profile-screen-tab key={t.id} type="button" role="tab" data-tab={t.id} aria-selected={t.id == tab ? "true" : "false"}                                    tabindex={t.id == tab ? "0" : "-1"} @click={() => { pick(t.id, false) }}>                                {t.label}{t.count != null && <small>{profile_screen_count(t.count)}</small>}                            </button>}                        </div>}                        <div.ui-profile-screen-panels                             @mount={(el) => {                                 panels = el                                 show(tab)                                 el.setAttribute("data-ready", "true")                             }}>{children}</div>                    </div>                </div>            </div>        </div>    </div>}
Thread
26Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.