Card
A stream, a clip, a room or a place: picture first, the title, who, and one line.
openvibe-shared/kitJSON
Build it
kit.card({ href: '/goosely', title: 'Seattle at night, chat picks the route', type: 'stream', live: true, who: 'goosely', count: [1204, 'watching'], sub: 'IRL · Capitol Hill' })Props
alt- string?
sub- string?
who- string?
date- any?
href- string?
live- boolean?
more- any?
peek- string?
save- any?
type- string?
when- any?
badge- string?
count- any?
image- string?
level- number?
ratio- string?
share- boolean?
title- string?
video- string?
avatar- string?
corner- string?
remind- any?
preview- string?
whoHtml- any?
Anatomy
- mediain the type's shape (16:9 streams and clips, 1:1 listings and memes, 4:3 games and reports); a picture, a text preview or the type's icon; background color.bg.hover; radius radius.md
- badgetop left, one word; LIVE is color.status.live.base; background rgba(0,0,0,.74); color #fff
- cornerbottom right: a length or a price
- titletwo lines at most; color color.text.primary; size type.size.body; weight type.weight.bold
- subone plain line; color color.text.secondary; size type.size.meta
- metawho · one count · when; a count of 0 is left out; color color.text.muted; size type.size.meta
- morethe ⋯ menu: Save, Remind, Share
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 (the heading's link)
- Target
- the whole card
- Focus
- Tab to the card, Enter opens it; the menu is its own stop
States
- default
- hover (the picture zooms, the peek shows)
- focused
- live
Tokens
color.bg.hoverradius.mdcolor.text.primarytype.size.bodytype.weight.boldcolor.text.secondarytype.size.metacolor.text.muted
Android · Jetpack Compose
Card
@Composable fun OvCard(title: String, onOpen: () -> Unit, image: String? = null, ratio: Float = 16f / 9f, badge: String? = null, sub: String? = null, meta: String? = null) { val c = MaterialTheme.colorScheme Column(Modifier.clickable(onClickLabel = title, onClick = onOpen).semantics(mergeDescendants = true) { heading() }) { Box(Modifier.fillMaxWidth().aspectRatio(ratio).clip(RoundedCornerShape(OvScale.RadiusMd)).background(c.surfaceVariant)) { if (image != null) AsyncImage(image, contentDescription = null, Modifier.matchParentSize(), contentScale = ContentScale.Crop) if (badge != null) Text(badge, Modifier.padding(OvScale.SpaceSm).background(Color(0xBD000000), RoundedCornerShape(6.dp)).padding(horizontal = 8.dp), color = Color.White, fontWeight = FontWeight.ExtraBold, fontSize = 11.sp) } Spacer(Modifier.height(OvScale.SpaceMd)) Text(title, color = c.onSurface, fontWeight = FontWeight.Bold, fontSize = OvScale.TypeSizeBody, maxLines = 2, overflow = TextOverflow.Ellipsis) if (sub != null) Text(sub, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1) if (meta != null) Text(meta, color = c.onSurfaceVariant, fontSize = OvScale.TypeSizeMeta, maxLines = 1) } }
iOS · SwiftUI
struct OvCard: View { let title: String var image: URL? = nil var ratio: CGFloat = 16.0 / 9.0 var badge: String? = nil var sub: String? = nil var meta: String? = nil var body: some View { VStack(alignment: .leading, spacing: 2) { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: OvScale.radiusMd).fill(OvTokens.colorBgHover) if let image { AsyncImage(url: image) { $0.resizable().scaledToFill() } placeholder: { Color.clear } } if let badge { Text(badge).font(.caption2.weight(.heavy)).foregroundStyle(.white).padding(.horizontal, 8).background(.black.opacity(0.74), in: .rect(cornerRadius: 6)).padding(OvScale.spaceSm) } } .aspectRatio(ratio, contentMode: .fit).clipShape(.rect(cornerRadius: OvScale.radiusMd)) .padding(.bottom, OvScale.spaceSm) Text(title).font(.system(size: OvScale.typeSizeBody, weight: .bold)).foregroundStyle(OvTokens.colorTextPrimary).lineLimit(2) if let sub { Text(sub).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextSecondary).lineLimit(1) } if let meta { Text(meta).font(.system(size: OvScale.typeSizeMeta)).foregroundStyle(OvTokens.colorTextMuted).lineLimit(1) } } .accessibilityElement(children: .combine).accessibilityAddTraits(.isLink) } }
Markup
Default
<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, chat picks the route</a></h3><p class="ovk-card-sub">IRL · Capitol Hill</p><p class="ovk-card-meta"><span class="ovk-card-who">goosely</span><span>1.2k watching</span></p></div><details class="ovk-more"><summary class="ovk-icon-btn" aria-label="More: Seattle at night, chat picks the route" title="More: Seattle at night, chat picks the route"><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, chat picks the route"><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>