Mobile

StickySectionFeed

Sections whose header sticks and is replaced by the next as you scroll, with a date-style floating label, and jump to today.

#mobile#interactive#tablet#feed#date#list

Demo

Preview

20 Oct

Tuesday

Friday

Yesterday

Today

Tomorrow

20 Nov

5 Jan 2027

Picked none; section none

20 Oct

  • Older item 1Harbour Cafe−3.20
  • Older item 2Station Diner−4.20
  • Older item 3Harbour Cafe−5.20
  • Older item 4Station Diner−6.20
  • Older item 5Harbour Cafe−7.20
  • Older item 6Station Diner−8.20

Tuesday

  • Tuesday item 1Harbour Cafe−3.20
  • Tuesday item 2Station Diner−4.20
  • Tuesday item 3Harbour Cafe−5.20
  • Tuesday item 4Station Diner−6.20
  • Tuesday item 5Harbour Cafe−7.20
  • Tuesday item 6Station Diner−8.20
  • Tuesday item 7Harbour Cafe−9.20

Friday

  • Friday item 1Harbour Cafe−3.20
  • Friday item 2Station Diner−4.20
  • Friday item 3Harbour Cafe−5.20
  • Friday item 4Station Diner−6.20
  • Friday item 5Harbour Cafe−7.20

Yesterday

  • Yesterday item 1Harbour Cafe−3.20
  • Yesterday item 2Station Diner−4.20
  • Yesterday item 3Harbour Cafe−5.20
  • Yesterday item 4Station Diner−6.20
  • Yesterday item 5Harbour Cafe−7.20
  • Yesterday item 6Station Diner−8.20

Today

  • Today item 1Harbour Cafe−3.20
  • Today item 2Station Diner−4.20
  • Today item 3Harbour Cafe−5.20
  • Today item 4Station Diner−6.20
  • Today item 5Harbour Cafe−7.20
  • Today item 6Station Diner−8.20
  • Today item 7Harbour Cafe−9.20
  • Today item 8Station Diner−10.20

Tomorrow

  • Tomorrow item 1Harbour Cafe−3.20
  • Tomorrow item 2Station Diner−4.20
  • Tomorrow item 3Harbour Cafe−5.20
  • Tomorrow item 4Station Diner−6.20
  • Tomorrow item 5Harbour Cafe−7.20

20 Nov

  • Later item 1Harbour Cafe−3.20
  • Later item 2Station Diner−4.20
  • Later item 3Harbour Cafe−5.20
  • Later item 4Station Diner−6.20
  • Later item 5Harbour Cafe−7.20
  • Later item 6Station Diner−8.20
  • Later item 7Harbour Cafe−9.20

5 Jan 2027

  • Next year item 1Harbour Cafe−3.20
  • Next year item 2Station Diner−4.20
  • Next year item 3Harbour Cafe−5.20
  • Next year item 4Station Diner−6.20
  • Next year item 5Harbour Cafe−7.20
  • Next year item 6Station Diner−8.20
  • Next year item 7Harbour Cafe−9.20

1 Nov

  • Early item 1Harbour Cafe−3.20
  • Early item 2Station Diner−4.20
  • Early item 3Harbour Cafe−5.20
  • Early item 4Station Diner−6.20
  • Early item 5Harbour Cafe−7.20
  • Early item 6Station Diner−8.20

Sunday

  • Near item 1Harbour Cafe−3.20
  • Near item 2Station Diner−4.20
  • Near item 3Harbour Cafe−5.20
  • Near item 4Station Diner−6.20
  • Near item 5Harbour Cafe−7.20
  • Near item 6Station Diner−8.20

12 Nov

  • Tie item 1Harbour Cafe−3.20
  • Tie item 2Station Diner−4.20
  • Tie item 3Harbour Cafe−5.20
  • Tie item 4Station Diner−6.20
  • Tie item 5Harbour Cafe−7.20
  • Tie item 6Station Diner−8.20

Undated

  • Bad date

Own title

  • Fine

No activity

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

Example

The code of the preview above.

demos/sticky_section_feed.qwe
fn sticky_section_feed_demo_rows(tag: str, n: int) -> list =    range(0, n) | map((i) => { id: `${tag}-${i}`, title: `${tag} item ${i + 1}`, text: i % 2 == 0 ? "Harbour Cafe" : "Station Diner", meta: `−${i + 3}.20` })let sticky_section_feed_demo_sections = [    { date: "2026-10-20", items: sticky_section_feed_demo_rows("Older", 6) },    { date: "2026-10-27", items: sticky_section_feed_demo_rows("Tuesday", 7) },    { date: "2026-10-30", items: sticky_section_feed_demo_rows("Friday", 5) },    { date: "2026-10-31", items: sticky_section_feed_demo_rows("Yesterday", 6) },    { date: "2026-11-01", items: sticky_section_feed_demo_rows("Today", 8) },    { date: "2026-11-02", items: sticky_section_feed_demo_rows("Tomorrow", 5) },    { date: "2026-11-20", items: sticky_section_feed_demo_rows("Later", 7) },    { date: "2027-01-05", items: sticky_section_feed_demo_rows("Next year", 7) }]component StickySectionFeedDemoLive() {    state picked = "none"    state sec = "none"    <div.demo-stack>        <div.demo-phone style="height: 600px">            <StickySectionFeed id="secfeed-demo" sections={sticky_section_feed_demo_sections} today="2026-11-01" height="100%" label="Activity"                               on_select={(it) => { picked = it.title }} on_section={(n) => { sec = str(n) }}/>        </div>        <p.demo-note>Picked <b data-picked>{picked}</b>; section <b data-sec>{sec}</b></p>        <div.demo-phone style="height: 300px">            <StickySectionFeed id="secfeed-start" sections={sticky_section_feed_demo_sections} today="2026-11-01" height="100%" start_today={true}/>        </div>        <div.demo-phone style="height: 300px">            <StickySectionFeed id="secfeed-near" today="2026-11-10" height="100%" today_text="Closest" sections={[                { date: "2026-11-01", items: sticky_section_feed_demo_rows("Early", 6) },                { date: "2026-11-08", items: sticky_section_feed_demo_rows("Near", 6) },                { date: "2026-11-12", items: sticky_section_feed_demo_rows("Tie", 6) }]}/>        </div>        <StickySectionFeed id="secfeed-bad" height="170px" sections={[null, { date: "2026-13-40", items: [{ title: "Bad date" }] }, { title: "Own title", items: [null, { title: "Fine" }] }, { items: "x" }]}/>        <StickySectionFeed id="secfeed-none" height="100px" empty="No activity"/>    </div>}view StickySectionFeedDemo() = <StickySectionFeedDemoLive/>

Source

ui/sticky_section_feed.qwe
// StickySectionFeed: a feed cut into dated sections, as in a calendar's agenda, a bank's// activity or a chat. Each section's heading sticks to the top until the next pushes it// away. While the feed moves, a pill floats below the heading with the full date, then// fades. When today's section is out of sight a button jumps back to it, its arrow saying// which way. In a wide pane the headings stand in a column of their own beside the// rows, so the pill is not needed. The sticking is the browser's own, so it works from// the first paint, in every engine, with no script.////   <StickySectionFeed today="2026-11-01" height="100%" on_select={(it) => { open(it.id) }} sections={[//       { date: "2026-11-01", items: [{ id: 1, title: "Coffee", text: "Harbour Cafe", meta: "−3.20" }] },//       { date: "2026-10-31", items: [{ id: 2, title: "Rent", meta: "−900.00" }] }//   ]}/>//// sections: a list of { date?, title?, items }, in the order to show. date: YYYY-MM-DD,// which gives the heading (Today, Yesterday, a weekday within the week, else "12 Oct") and// the pill's full date. title: words of your own in place of that heading.// items: a list of { id?, title, text?, meta? }. today: the date it is, a prop so that// server and browser agree; with none, no heading says Today and there is no button.// With no section for today, the button goes to the nearest date.// height: how tall it stands, a CSS length ("100dvh"); "100%" fills a parent that has a// height. start_today: open at today instead of the top (false). today_text: the button's// words ("Today"). empty: what shows with nothing ("Nothing here"). label: what screen// readers call the feed ("Feed"). id, class: on the outer element.// on_select(item): inside one of your components, a tap on a row; rows are plain text// without it. on_section(n): the section at the top changed, its place in the list.//// The scroller can take focus, so arrow and Page keys scroll it. A jump moves focus to the// section's heading, so a keyboard or screen reader goes on from there.import ui/theme.qwe use ui_clslet sticky_section_feed_css = css`.ui-secfeed { position: relative; container-type: inline-size; box-sizing: border-box; overflow: hidden; background: var(--ui-bg); color: var(--ui-fg); font: 16px/1.3 var(--ui-font); }.ui-secfeed-scroll { position: relative; box-sizing: border-box; height: 100%; overflow-y: auto; overscroll-behavior-y: contain; outline: none; }.ui-secfeed-scroll:focus-visible { box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-secfeed-section { position: relative; }.ui-secfeed-head { position: sticky; top: 0; z-index: 2; margin: 0; padding: 8px max(16px, env(safe-area-inset-left)); border-bottom: 1px solid var(--ui-border); background: var(--ui-subtle); font: 600 14px/1.4 var(--ui-font); outline: none; }.ui-secfeed-today .ui-secfeed-head { color: var(--ui-accent); }.ui-secfeed-rows { margin: 0; padding: 0; list-style: none; }.ui-secfeed-row { display: flex; align-items: center; gap: 10px; box-sizing: border-box; width: 100%; min-height: 56px; padding: 8px 16px 8px max(16px, env(safe-area-inset-left)); border: 0; border-bottom: 1px solid var(--ui-border); background: transparent; color: inherit; font: inherit; text-align: left; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }button.ui-secfeed-row { cursor: pointer; }button.ui-secfeed-row:active { background: color-mix(in srgb, var(--ui-fg) 8%, transparent); }.ui-secfeed-row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--ui-accent); }.ui-secfeed-text { flex: 1; min-width: 0; overflow-wrap: anywhere; }.ui-secfeed-sub { display: block; color: var(--ui-muted); font-size: 14px; }.ui-secfeed-meta { flex: none; color: var(--ui-muted); font-size: 14px; font-variant-numeric: tabular-nums; }.ui-secfeed-pill { position: absolute; top: 46px; left: 50%; z-index: 3; max-width: calc(100% - 32px); padding: 5px 12px; border-radius: 999px; background: var(--ui-fg); color: var(--ui-bg); font: 500 13px/1.3 var(--ui-font); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: var(--ui-shadow); pointer-events: none; opacity: 0; transform: translate(-50%, -6px); transition: opacity .18s, transform .18s; }.ui-secfeed-pill[data-on] { opacity: 1; transform: translate(-50%, 0); }.ui-secfeed-jump { position: absolute; right: 16px; bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 3; display: inline-flex; align-items: center; gap: 6px; min-width: 44px; min-height: 46px; padding: 0 16px; border: 0; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 15px/1 var(--ui-font); box-shadow: var(--ui-shadow-lg); cursor: pointer; touch-action: manipulation; }.ui-secfeed-jump:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-secfeed-jump svg { width: 16px; height: 16px; }.ui-secfeed-none { margin: 40px 16px; color: var(--ui-muted); text-align: center; }@container (min-width: 640px) {  .ui-secfeed-section { display: grid; grid-template-columns: 170px minmax(0, 1fr); border-bottom: 1px solid var(--ui-border); }  .ui-secfeed-head { align-self: start; padding: 14px 16px; border-bottom: 0; background: transparent; font-size: 15px; }  .ui-secfeed-pill { display: none; }  .ui-secfeed-rows li:last-child .ui-secfeed-row { border-bottom: 0; }}@media (hover: hover) { button.ui-secfeed-row:hover { background: color-mix(in srgb, var(--ui-fg) 4%, transparent); } }@media (prefers-reduced-motion: reduce) { .ui-secfeed-pill { transition: none; } }`// The sections that can be shown, each with its place.fn sticky_section_feed_clean(sections) -> list {    let given = type_of(sections) == "list" ? sections | filter((s) => type_of(s) == "map") : []    let kept = given | map((s) => {        let items = get(s, "items", [])        { date: get(s, "date"), title: get(s, "title"), items: type_of(items) == "list" ? items | filter((x) => type_of(x) == "map") : [] }    })    enumerate(kept | filter((s) => len(s.items) > 0)) | map((p) => { ...p[1], n: p[0] })}// A date as a text that can be counted from, or null.fn sticky_section_feed_day(d) -> str? = type_of(d) == "str" && date.valid(d, "date") ? d : null// A section's heading: its own title, else from its date, counted from today.fn sticky_section_feed_heading(s, today) -> str {    if get(s, "title") != null { return str(s.title) }    let d = sticky_section_feed_day(get(s, "date"))    if d == null { return "Undated" }    let now_day = sticky_section_feed_day(today)    let gap = now_day != null ? date.days_between(d, now_day) : null    if gap == 0 { return "Today" }    if gap == 1 { return "Yesterday" }    if gap == -1 { return "Tomorrow" }    if gap != null && gap > 1 && gap < 7 { return date.format(d, "dddd") }    now_day != null && slice(d, 0, 4) == slice(now_day, 0, 4) ? date.format(d, "D MMM") : date.format(d, "D MMM YYYY")}// The pill's words: the whole date, else the heading.fn sticky_section_feed_full(s, today) -> str {    let d = sticky_section_feed_day(get(s, "date"))    d != null ? date.format(d, "dddd D MMMM YYYY") : sticky_section_feed_heading(s, today)}// The place of today's section, or of the date nearest it, the earlier on a tie. Null with no dates.fn sticky_section_feed_nearest(sections: list, today) -> int? {    let now_day = sticky_section_feed_day(today)    if now_day == null { return null }    let dated = sections | filter((s) => sticky_section_feed_day(s.date) != null)    if len(dated) == 0 { return null }    (dated | sort_by((s) => {        let gap = date.days_between(now_day, s.date)        abs(gap) * 2 + (gap > 0 ? 1 : 0)    }))[0].n}// Which section stands at a scroll position: the last that starts at or above it.fn sticky_section_feed_at(tops: list, y: num) -> int {    if len(tops) == 0 { return 0 }    let lo = 0    let hi = len(tops) - 1    while lo < hi {        let mid = ceil((lo + hi) / 2)        if tops[mid] <= y { lo = mid } else { hi = mid - 1 }    }    lo}// Whether a section between top and bottom shows in a view of that height at y.fn sticky_section_feed_seen(top: num, bottom: num, y: num, tall: num) -> bool = bottom > y + 1 && top < y + tall - 1component StickySectionFeed(sections = [], today = null, height: str = "100dvh", start_today: bool = false, today_text: str = "Today", empty: str = "Nothing here",                            label: str = "Feed", on_select = null, on_section = null, id: str? = null, class: str? = null) {    style global sticky_section_feed_css    let clean = sticky_section_feed_clean(sections)    let home = sticky_section_feed_nearest(clean, today)    state box = null    state tops = []    state ends = []    state at = 0    state away = false    state moving = false    state last_move = 0    state still = false    let measure = () => {        if box != null {            let parts = js("Array.from", box.querySelectorAll("[data-n]"))            tops = parts | map((p) => p.offsetTop)            ends = parts | map((p) => p.offsetTop + p.offsetHeight)        }    }    // Where the feed stands, read from the box, not kept per frame in state beyond what changed.    let place = () => {        if box != null && len(tops) > 0 {            let i = sticky_section_feed_at(tops, box.scrollTop + 1)            if i != at {                at = i                on_section && on_section(i)            }            away = home != null && home < len(tops) && !sticky_section_feed_seen(tops[home], ends[home], box.scrollTop, box.clientHeight)        }    }    let go_home = () => {        if home != null && box != null && home < len(tops) {            box.scrollTo({ top: tops[home], behavior: still ? "instant" : "smooth" })            let head = box.querySelector(`[data-n="${home}"] h3`)            if head != null { head.focus({ preventScroll: true }) }        }    }    // `every` is the only clock: while the feed moves it looks each quarter second for quiet.    if moving {        every(250, () => { if now() - last_move > 800 { moving = false } })    }    <div class={ui_cls(["ui-secfeed", class])} id={id} style={`height: ${height}`}>        <div.ui-secfeed-scroll tabindex="0" role="group" aria-label={label}             @mount={(el) => {                 still = js("matchMedia", "(prefers-reduced-motion: reduce)").matches                 box = el                 measure()                 if start_today && home != null && home < len(tops) { el.scrollTop = tops[home] }                 place()                 // New heights, from a rotation or from rows that changed.                 let ro = js_new("ResizeObserver", () => {                     measure()                     place()                 })                 ro.observe(el)                 ro.observe(el.firstElementChild)                 () => ro.disconnect()             }}             @scroll={() => {                 last_move = now()                 moving = true                 place()             }}>            <div>                {len(clean) == 0 && <p.ui-secfeed-none>{empty}</p>}                {for s in clean => <section key={s.n} data-n={s.n} class={ui_cls(["ui-secfeed-section", home == s.n && "ui-secfeed-today"])}>                    <h3.ui-secfeed-head tabindex="-1">{sticky_section_feed_heading(s, today)}</h3>                    <ul.ui-secfeed-rows>                        {for [j, it] in enumerate(s.items) => {                            let body = [<span.ui-secfeed-text>{str(get(it, "title") ?? "")}{get(it, "text") != null && <span.ui-secfeed-sub>{it.text}</span>}</span>,                                        get(it, "meta") != null ? <span.ui-secfeed-meta>{it.meta}</span> : null]                            <li key={j}>{on_select != null                                ? <button.ui-secfeed-row type="button" @click={() => { on_select(it) }}>{body}</button>                                : <div.ui-secfeed-row>{body}</div>}</li>                        }}                    </ul>                </section>}            </div>        </div>        {at < len(clean) && <div.ui-secfeed-pill aria-hidden="true" data-on={moving ? "" : null}>{sticky_section_feed_full(clean[at], today)}</div>}        {away && <button.ui-secfeed-jump type="button" @click={go_home}>            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.500" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"                 style={at < (home ?? 0) ? null : "transform: rotate(180deg)"}><path d="M12 5v14M6 13l6 6 6-6"/></svg>            {today_text}        </button>}    </div>}
Thread
35Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.