Next Meeting Card
A next-meeting widget that counts down to start, turns its Join button solid when it’s time, and lists who’s attending.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/meeting-card.json'use client'
import { useEffect, useState } from 'react'
import './meeting-card.css'
export interface Meeting { title: string; start: Date; end: Date; people: { name: string; rsvp: 'yes' | 'no' | 'maybe' }[]; link: string }
export interface MeetingCardProps {
meetings: Meeting[]
joinWindow?: number
fill?: 'dark' | 'light'
className?: string
}
/** Next-meeting widget with countdown and join state. */
export function MeetingCard({ meetings, joinWindow = 5, fill = 'dark', className }: MeetingCardProps) {
const [now, setNow] = useState(() => Date.now())
const [copied, setCopied] = useState(false)
useEffect(() => { const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t) }, [])
const m = meetings.find((x) => x.end.getTime() > now)
if (!m) return <div className={['sd-mtg', className].filter(Boolean).join(' ')} data-fill={fill}><div className="sd-mtg__t">No more meetings today</div></div>
const ms = m.start.getTime() - now
const live = ms <= joinWindow * 60000
const started = ms <= 0
const rel = started ? 'Now' : ms < 60000 ? `in ${Math.ceil(ms / 1000)}s` : ms < 3600000 ? `in ${Math.ceil(ms / 60000)} min` : `in ${Math.floor(ms / 3600000)}h ${Math.ceil((ms % 3600000) / 60000)}m`
const t = (d: Date) => d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
const going = m.people.filter((p) => p.rsvp === 'yes').length
return (
<div className={['sd-mtg', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-mtg__when"><span>{t(m.start)} – {t(m.end)}</span><b data-now={started || undefined}>{rel}</b></div>
<div className="sd-mtg__t">{m.title}</div>
<div className="sd-mtg__ppl">
<span className="sd-mtg__stack">{m.people.slice(0, 5).map((p) => <span key={p.name} className="sd-mtg__av" data-rsvp={p.rsvp} aria-label={`${p.name}: ${p.rsvp === 'yes' ? 'going' : p.rsvp === 'no' ? 'declined' : 'maybe'}`}>{p.name.slice(0, 2)}</span>)}</span>
{going} of {m.people.length} going
</div>
<div className="sd-mtg__acts">
<a className="sd-mtg__join" data-live={live || undefined} href={m.link} target="_blank" rel="noreferrer">{started ? 'Join now' : live ? 'Join' : 'Join link'}</a>
<button type="button" className="sd-mtg__copy" aria-label={copied ? 'Link copied' : 'Copy meeting link'} onClick={async () => { try { await navigator.clipboard.writeText(m.link) } catch {} setCopied(true); setTimeout(() => setCopied(false), 1400) }}>
{copied ? <svg viewBox="0 0 16 16" aria-hidden><path d="m3.5 8.5 3 3 6-7" /></svg> : <svg viewBox="0 0 16 16" aria-hidden><path d="M7 9a3 3 0 0 0 4.2 0l2-2a3 3 0 0 0-4.2-4.2l-.6.6M9 7a3 3 0 0 0-4.2 0l-2 2A3 3 0 0 0 7 13.2l.6-.6" /></svg>}
</button>
</div>
<span className="sr" aria-live="polite">{started ? `${m.title} has started` : ''}</span>
</div>
)
}.sd-mtg { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-mtg[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-mtg button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-mtg :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-mtg .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-mtg { width: min(100%, 320px); padding: 16px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-mtg__when { display: flex; justify-content: space-between; font-size: 12.5px; color: var(--mute); font-variant-numeric: tabular-nums; }
.sd-mtg__when b { color: var(--fg); font-weight: 650; }
.sd-mtg__when b[data-now] { padding: 1px 8px; border-radius: 99px; background: var(--strong); color: var(--on); }
.sd-mtg__t { margin: 8px 0 12px; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
.sd-mtg__ppl { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--mute); }
.sd-mtg__stack { display: flex; }
.sd-mtg__av { position: relative; display: grid; place-items: center; width: 28px; height: 28px; margin-left: -6px; border-radius: 99px; background: var(--soft); box-shadow: 0 0 0 2px var(--bg); font-size: 10.5px; font-weight: 650; color: var(--fg); }
.sd-mtg__av:first-child { margin-left: 0; }
.sd-mtg__av[data-rsvp='no'] { opacity: 0.4; text-decoration: line-through; }
.sd-mtg__av[data-rsvp='maybe'] { box-shadow: 0 0 0 2px var(--bg), inset 0 0 0 1px var(--mute); background: none; }
.sd-mtg__acts { display: flex; gap: 6px; margin-top: 14px; }
.sd-mtg__join { flex: 1; display: grid; place-items: center; height: 42px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg); font-weight: 600; font-size: 14px; text-decoration: none; transition: background-color 300ms, color 300ms; }
.sd-mtg__join[data-live] { background: var(--strong); color: var(--on); box-shadow: none; animation: sd-mtg-nudge 2.4s ease-in-out infinite; }
@keyframes sd-mtg-nudge { 0%, 85%, 100% { transform: none; } 90% { transform: translateY(-2px); } }
.sd-mtg__copy { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-mtg__copy svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
@media (prefers-reduced-motion: reduce) { .sd-mtg *, .sd-mtg *::before, .sd-mtg *::after { animation: none !important; transition: none !important; } }Overview
Shows the next meeting with a relative countdown (“in 4 min”). Within the join window the Join button becomes primary and the countdown switches to “Now”; after the end time it rolls to the next meeting.
Attendees show as initials with their RSVP state, and a copy-link button sits beside Join.
When to use
- Dashboards, sidebars, calendar apps, launchers.
When not to use
- Full schedules: use an agenda.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/meeting-card.json$
npx shadcn@latest add https://sundaeui.dev/r/meeting-card.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/meeting-card.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/meeting-card.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy meeting-card.tsx and meeting-card.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { MeetingCard } from '@/components/sundae/meeting-card'
<MeetingCard />Props
| Prop | Type | Default |
|---|---|---|
meetings*Upcoming meetings, soonest first. | { title: string; start: Date; end: Date; people: { name: string; rsvp: "yes" | "no" | "maybe" }[]; link: string }[] | — |
joinWindowMinutes before start when Join goes primary. | number | 5 |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The countdown updates a visually hidden label once a minute.
- Join is a link; RSVP states are in each attendee’s label.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-mtg; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Next Meeting Card free?
- Yes. Next Meeting Card is free for personal and commercial projects, with full source on this page.
- How do I install Next Meeting Card?
- Run npx shadcn@latest add https://sundaeui.dev/r/meeting-card.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Next Meeting Card support keyboard and screen readers?
- The countdown updates a visually hidden label once a minute. Join is a link; RSVP states are in each attendee’s label.
- Does Next Meeting Card respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Call Grid
Components · Refined- Pro
Focus Timer
Components · Refined Timezone Slider
Components · RefinedRSVP Card
Components · Refined- Pro
Day Agenda
Components · Refined Event Calendar
Components · Refined