StickySectionFeed
Sections whose header sticks and is replaced by the next as you scroll, with a date-style floating label, and jump to today.
Demo
20 Oct
Tuesday
Friday
Yesterday
Today
Tomorrow
20 Nov
5 Jan 2027
Picked none; section none
20 Oct
- Older item 1Harbour Cafe
- Older item 2Station Diner
- Older item 3Harbour Cafe
- Older item 4Station Diner
- Older item 5Harbour Cafe
- Older item 6Station Diner
Tuesday
- Tuesday item 1Harbour Cafe
- Tuesday item 2Station Diner
- Tuesday item 3Harbour Cafe
- Tuesday item 4Station Diner
- Tuesday item 5Harbour Cafe
- Tuesday item 6Station Diner
- Tuesday item 7Harbour Cafe
Friday
- Friday item 1Harbour Cafe
- Friday item 2Station Diner
- Friday item 3Harbour Cafe
- Friday item 4Station Diner
- Friday item 5Harbour Cafe
Yesterday
- Yesterday item 1Harbour Cafe
- Yesterday item 2Station Diner
- Yesterday item 3Harbour Cafe
- Yesterday item 4Station Diner
- Yesterday item 5Harbour Cafe
- Yesterday item 6Station Diner
Today
- Today item 1Harbour Cafe
- Today item 2Station Diner
- Today item 3Harbour Cafe
- Today item 4Station Diner
- Today item 5Harbour Cafe
- Today item 6Station Diner
- Today item 7Harbour Cafe
- Today item 8Station Diner
Tomorrow
- Tomorrow item 1Harbour Cafe
- Tomorrow item 2Station Diner
- Tomorrow item 3Harbour Cafe
- Tomorrow item 4Station Diner
- Tomorrow item 5Harbour Cafe
20 Nov
- Later item 1Harbour Cafe
- Later item 2Station Diner
- Later item 3Harbour Cafe
- Later item 4Station Diner
- Later item 5Harbour Cafe
- Later item 6Station Diner
- Later item 7Harbour Cafe
5 Jan 2027
- Next year item 1Harbour Cafe
- Next year item 2Station Diner
- Next year item 3Harbour Cafe
- Next year item 4Station Diner
- Next year item 5Harbour Cafe
- Next year item 6Station Diner
- Next year item 7Harbour Cafe
1 Nov
- Early item 1Harbour Cafe
- Early item 2Station Diner
- Early item 3Harbour Cafe
- Early item 4Station Diner
- Early item 5Harbour Cafe
- Early item 6Station Diner
Sunday
- Near item 1Harbour Cafe
- Near item 2Station Diner
- Near item 3Harbour Cafe
- Near item 4Station Diner
- Near item 5Harbour Cafe
- Near item 6Station Diner
12 Nov
- Tie item 1Harbour Cafe
- Tie item 2Station Diner
- Tie item 3Harbour Cafe
- Tie item 4Station Diner
- Tie item 5Harbour Cafe
- Tie item 6Station Diner
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.qwemain.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>}Related
InfiniteFeedA long feed that loads pages as you near the end: loading, retry on failure, end of list, keeps its place on return, and stays light by windowing.3 views · 5 downloadsAlphaRailListA long list with sticky letter headers and an A to Z rail scrubbed by finger, with a bubble showing the letter and a haptic per step.1 view · 6 downloadsContactListPeople grouped by letter with avatars from initials, fast search across names and numbers, a call and message action per row.3 views · 8 downloadsDragReorderPut a list in order by long press and drag, scrolling at its edges, and by keys or buttons too.2 views · 7 downloadsExpandingRowA row that opens in place with content pushed down smoothly, keeps only one open if asked, and scrolls itself into view.4 views · 7 downloadsGroupedListiOS-style inset grouped rows with headers, footers and disclosure chevrons, full-row tap targets, and dark mode.2 views · 7 downloads
Thread
Thread 0 posts
On StickySectionFeed: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.