Rail
A row of cards that scrolls sideways on a phone and wraps on a computer when asked. A long list never makes a long page.
openvibe-shared/kitJSON
Build it
kit.rail({ id: 'now', title: 'Live now', more: { href: '/live', label: 'See all' }, items: [kit.card({ href: '/goosely', title: 'Seattle at night', live: true, who: 'goosely', type: 'stream' }), kit.card({ href: '/surfwax', title: 'Surf race, heat 3', live: true, who: 'surfwax', type: 'stream' }), kit.card({ href: '/v/91', title: 'Building a robot arm, day 12', who: 'ana', type: 'video', count: [880, 'views'] }), kit.card({ href: '/v/92', title: 'Lo-fi and a rainy window', who: 'kai', type: 'video' })] })Props
id- string?
icon- string?
more- any?
size- string?
wrap- boolean?
items- list?
label- string?
title- string?
Anatomy
- headthe title and See all (44 px to the touch); size type.size.lead; weight type.weight.heavy
- tracksnaps to each card; bleeds to the screen's edge on a phone; gap space.md
- arrows‹ › on computers only, each hidden at its end
Behaviour and access
- Keyboard
- Standard focus order
- Outside tap
- Leaves it open
- Scroll
- The page still scrolls
- Reduced motion
- Remove movement; keep the state change.
- Name
- its title
- Target
- See all 44 px
- Focus
- Tab moves card to card; the arrows are buttons
States
- default
- empty
- loading
Tokens
type.size.leadtype.weight.heavyspace.md
Android · Jetpack Compose
Column
@Composable fun OvRail(title: String, seeAll: (() -> Unit)? = null, content: LazyListScope.() -> Unit) { Column(Modifier.semantics { heading() }) { Row(Modifier.fillMaxWidth().padding(horizontal = OvScale.SpaceLg), verticalAlignment = Alignment.CenterVertically) { Text(title, Modifier.weight(1f), fontWeight = FontWeight.ExtraBold, fontSize = OvScale.TypeSizeLead) if (seeAll != null) TextButton(seeAll, Modifier.heightIn(min = OvScale.SizeTap)) { Text("See all") } } val state = rememberLazyListState() LazyRow(state = state, flingBehavior = rememberSnapFlingBehavior(state), contentPadding = PaddingValues(horizontal = OvScale.SpaceLg), horizontalArrangement = Arrangement.spacedBy(OvScale.SpaceMd), content = content) } }
iOS · SwiftUI
struct OvRail<Content: View>: View { let title: String var seeAll: (() -> Void)? = nil @ViewBuilder var content: () -> Content var body: some View { VStack(alignment: .leading, spacing: OvScale.spaceMd) { HStack { Text(title).font(.system(size: OvScale.typeSizeLead, weight: .heavy)).accessibilityAddTraits(.isHeader) Spacer() if let seeAll { Button("See all", action: seeAll).frame(minHeight: OvScale.sizeTap) } }.padding(.horizontal, OvScale.spaceLg) ScrollView(.horizontal, showsIndicators: false) { LazyHStack(spacing: OvScale.spaceMd) { content() }.scrollTargetLayout().padding(.horizontal, OvScale.spaceLg) }.scrollTargetBehavior(.viewAligned) } } }
Markup
Default
<section class="ovk-rail ovk-rail--m" id="now" aria-labelledby="now-h"><div class="ovk-rail-head"><h2 id="now-h">Live now</h2><a class="ovk-rail-more" href="/live">See all<svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9.9 6.6 6.6 6.6 -6.6 6.6"/></svg></a></div><div class="ovk-rail-wrap"><ul class="ovk-rail-track" role="list"><li><article class="ovk-card is-live ovk-card--stream" style="--ovk-ar:16/9"><div class="ovk-card-media"><span class="ovk-card-noimg"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="2.15"/><path d="M8.4 8.4a5.15 5.15 0 0 0 0 7.55M15.6 8.4a5.15 5.15 0 0 1 0 7.55M6 6a8.55 8.55 0 0 0 0 12.6M18 6a8.55 8.55 0 0 1 0 12.6"/></svg></span><span class="ovk-card-badge is-live">LIVE</span></div><div class="ovk-card-body"><span class="ovk-mark" style="--ovk-mark:34px;--ovk-hue:350" aria-hidden="true">GO</span><div class="ovk-card-text"><h3 class="ovk-card-title"><a class="ovk-card-link" href="/goosely">Seattle at night</a></h3><p class="ovk-card-meta"><span class="ovk-card-who">goosely</span></p></div><details class="ovk-more"><summary class="ovk-icon-btn" aria-label="More: Seattle at night" title="More: Seattle at night"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></summary><div class="ovk-pop" role="menu"><a class="ovk-pop-item" role="menuitem" href="/goosely" data-ovk-share="Seattle at night"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg><span>Share</span></a></div></details></div></article></li><li><article class="ovk-card is-live ovk-card--stream" style="--ovk-ar:16/9"><div class="ovk-card-media"><span class="ovk-card-noimg"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="2.15"/><path d="M8.4 8.4a5.15 5.15 0 0 0 0 7.55M15.6 8.4a5.15 5.15 0 0 1 0 7.55M6 6a8.55 8.55 0 0 0 0 12.6M18 6a8.55 8.55 0 0 1 0 12.6"/></svg></span><span class="ovk-card-badge is-live">LIVE</span></div><div class="ovk-card-body"><span class="ovk-mark" style="--ovk-mark:34px;--ovk-hue:304" aria-hidden="true">SU</span><div class="ovk-card-text"><h3 class="ovk-card-title"><a class="ovk-card-link" href="/surfwax">Surf race, heat 3</a></h3><p class="ovk-card-meta"><span class="ovk-card-who">surfwax</span></p></div><details class="ovk-more"><summary class="ovk-icon-btn" aria-label="More: Surf race, heat 3" title="More: Surf race, heat 3"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></summary><div class="ovk-pop" role="menu"><a class="ovk-pop-item" role="menuitem" href="/surfwax" data-ovk-share="Surf race, heat 3"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg><span>Share</span></a></div></details></div></article></li><li><article class="ovk-card ovk-card--video" style="--ovk-ar:16/9"><div class="ovk-card-media"><span class="ovk-card-noimg"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 5v14l11 -7Z"/></svg></span></div><div class="ovk-card-body"><span class="ovk-mark" style="--ovk-mark:34px;--ovk-hue:244" aria-hidden="true">AN</span><div class="ovk-card-text"><h3 class="ovk-card-title"><a class="ovk-card-link" href="/v/91">Building a robot arm, day 12</a></h3><p class="ovk-card-meta"><span class="ovk-card-who">ana</span><span>880 views</span></p></div><details class="ovk-more"><summary class="ovk-icon-btn" aria-label="More: Building a robot arm, day 12" title="More: Building a robot arm, day 12"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></summary><div class="ovk-pop" role="menu"><a class="ovk-pop-item" role="menuitem" href="/v/91" data-ovk-share="Building a robot arm, day 12"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg><span>Share</span></a></div></details></div></article></li><li><article class="ovk-card ovk-card--video" style="--ovk-ar:16/9"><div class="ovk-card-media"><span class="ovk-card-noimg"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M7 5v14l11 -7Z"/></svg></span></div><div class="ovk-card-body"><span class="ovk-mark" style="--ovk-mark:34px;--ovk-hue:99" aria-hidden="true">KA</span><div class="ovk-card-text"><h3 class="ovk-card-title"><a class="ovk-card-link" href="/v/92">Lo-fi and a rainy window</a></h3><p class="ovk-card-meta"><span class="ovk-card-who">kai</span></p></div><details class="ovk-more"><summary class="ovk-icon-btn" aria-label="More: Lo-fi and a rainy window" title="More: Lo-fi and a rainy window"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></summary><div class="ovk-pop" role="menu"><a class="ovk-pop-item" role="menuitem" href="/v/92" data-ovk-share="Lo-fi and a rainy window"><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M14 4h6v6M20 4l-9 9M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/></svg><span>Share</span></a></div></details></div></article></li></ul><button type="button" class="ovk-rail-btn ovk-rail-prev" aria-label="Scroll back" hidden><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M15 5l-7 7 7 7"/></svg></button><button type="button" class="ovk-rail-btn ovk-rail-next" aria-label="Scroll on" hidden><svg class="ovk-i" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg></button></div></section>