Lists and content

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.hover
  • radius.md
  • color.text.primary
  • type.size.body
  • type.weight.bold
  • color.text.secondary
  • type.size.meta
  • color.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>