16 KiB
status, date
| status | date |
|---|---|
| draft | 2025-02-04 |
Time Display
Displays current time, duration, or remaining time.
Problem
Video players need to display time:
- Current playback position — updates continuously
- Total duration — static once source loads
- Remaining time — duration minus current, often with negative sign
Requirements:
- Digital format:
1:30:45or5:30 - Conditional hour display (hide when duration < 1 hour)
- Proper accessibility (screen readers need human-readable time, not "1:30")
- Composable for common patterns like
1:30 / 5:00
Anatomy
React
import { Time } from '@videojs/react';
// Standalone
<Time.Value type="current" />
// Composed
<Time.Group>
<Time.Value type="current" />
<Time.Separator />
<Time.Value type="duration" />
</Time.Group>
HTML
import '@videojs/html/ui/time';
<!-- Standalone -->
<media-time type="current"></media-time>
<!-- Composed -->
<media-time-group>
<media-time type="current"></media-time>
<media-time-separator></media-time-separator>
<media-time type="duration"></media-time>
</media-time-group>
Examples
Current Time Only
<Time.Value />
Current / Duration
<Time.Group>
<Time.Value type="current" />
<Time.Separator />
<Time.Value type="duration" />
</Time.Group>
Remaining Time
<Time.Value type="remaining" />
// Renders: -4:30
Custom Separator
<Time.Group>
<Time.Value type="current" />
<Time.Separator> of </Time.Separator>
<Time.Value type="duration" />
</Time.Group>
// Renders: 1:30 of 5:00
Custom Negative Sign
<Time.Value type="remaining" negativeSign="−" />
// Renders: −4:30 (using proper minus sign U+2212)
Parts
Group
Container for composed time displays. Renders a <span> element.
Why a component?
- Semantic structure — Clear boundary around related time displays
- Future API surface — Room to add formatting options (e.g.,
hoursDisplay) that apply to all children - Styling container — Provides a target for layout and theming
Props
| Prop | Type | Default | Description |
|---|---|---|---|
render |
RenderProp<State> |
— | Custom render element. |
Data Attributes
| Attribute | Description |
|---|---|
| — | None currently. |
Renders
<span><!-- children --></span>
Value
Displays a formatted time value. Works standalone or within Group.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
type |
'current' | 'duration' | 'remaining' |
'current' |
Which time to display. |
negativeSign |
string |
'-' |
Symbol for remaining time. |
label |
string |
Auto | Custom aria-label. |
render |
RenderProp<State> |
— | Custom render element. |
State
| Property | Type | Description |
|---|---|---|
type |
TimeType |
'current', 'duration', or 'remaining' |
seconds |
number |
Raw value in seconds. |
text |
string |
Formatted display text (1:30). |
phrase |
string |
Human-readable phrase (1 minute, 30 seconds). |
datetime |
string |
ISO 8601 duration (PT1M30S). |
Data Attributes
| Attribute | Description |
|---|---|
data-type |
current, duration, or remaining. |
Renders
React — Uses <time> element for semantic value:
<time
datetime="PT1M30S"
aria-label="Current time"
aria-valuetext="1 minute, 30 seconds">
1:30
</time>
<!-- Remaining with negative sign -->
<time
datetime="PT4M30S"
aria-label="Remaining"
aria-valuetext="4 minutes, 30 seconds remaining">
<span aria-hidden="true">-</span>4:30
</time>
HTML — Renders text directly for simpler styling:
<media-time
type="current"
aria-label="Current time"
aria-valuetext="1 minute, 30 seconds">
1:30
</media-time>
<!-- Remaining with negative sign -->
<media-time
type="remaining"
aria-label="Remaining"
aria-valuetext="4 minutes, 30 seconds remaining">
<span aria-hidden="true">-</span>4:30
</media-time>
Separator
Divider between time values. Hidden from screen readers.
Why a custom element?
- Consistency — Fits the component model, composable with Group
- Automatic accessibility — Always applies
aria-hidden="true" - Styling — Can be targeted with CSS, has default spacing
- Prevents mistakes — Users don't need to remember
aria-hidden
Why aria-hidden="true"?
The separator is a decorative visual cue. Screen readers already hear two separate time values — announcing "slash" or "of" between them adds noise without meaning. Each <Time.Value> has its own aria-valuetext providing full context.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
'/' |
Separator content. |
render |
RenderProp<{}> |
— | Custom render element. |
Renders
<span aria-hidden="true">/</span>
Formatting
Digital Format
Time displays use colon-separated digital format:
| Duration | Display |
|---|---|
| 90 sec | 1:30 |
| 10 min | 10:00 |
| 1 hr 5 min 30 sec | 1:05:30 |
Padding Rules
Smart defaults handle padding automatically:
| Unit | Rule | Example |
|---|---|---|
| Hours | Never padded | 1:05:30 not 01:05:30 |
| Minutes | Padded when hours shown | 1:05:30 but 5:30 |
| Seconds | Always padded | 1:05 not 1:5 |
Hour Display
Hours are shown when the value or duration exceeds 1 hour. This ensures times within a Group display consistently — 1:30 / 1:05:00 won't happen; both would show hours.
Negative Sign
For type="remaining", a negative sign is prepended to indicate countdown. The negativeSign prop controls which symbol to use (default '-').
<Time.Value type="remaining" /> // -4:30
<Time.Value type="remaining" negativeSign="−" /> // −4:30 (proper minus sign)
The sign is rendered as <span aria-hidden="true">{negativeSign}</span>. To hide it, use CSS:
[data-type="remaining"] > span[aria-hidden] {
display: none;
}
Why aria-hidden="true"?
The aria-valuetext already says "4 minutes, 30 seconds remaining" — the word "remaining" conveys the meaning. Announcing "minus" or "dash" would be redundant.
Accessibility
Screen Reader Approach
Following Media Chrome's pattern:
| Attribute | Purpose | Example |
|---|---|---|
aria-label |
Static role label | "Current time" |
aria-valuetext |
Dynamic human-readable time | "1 minute, 30 seconds" |
Screen readers announce: "Current time, 1 minute, 30 seconds"
Labels
Default labels by type:
| Type | aria-label |
aria-valuetext example |
|---|---|---|
current |
"Current time" |
"1 minute, 30 seconds" |
duration |
"Duration" |
"5 minutes, 30 seconds" |
remaining |
"Remaining" |
"4 minutes, 30 seconds remaining" |
Override the label with the label prop. The aria-valuetext is always auto-generated from the time value.
Live Regions
Time displays do not use aria-live. Time updates too frequently and would overwhelm screen readers. Users who need time info can navigate to the control.
Separator
Hidden from screen readers with aria-hidden="true". Screen readers announce the two time values without the "/" between them.
React vs HTML Element Choice
React uses <time> — Provides semantic value via datetime attribute for machine parsing (SEO, parsers). Styling is controlled by the developer anyway.
HTML uses text directly — Custom elements can't extend <time>, and nesting <time> inside creates styling complexity. The aria-valuetext provides equivalent accessibility.
Architecture
File Structure
packages/
├── utils/src/time/
│ ├── format.ts # formatTime, formatTimeAsPhrase
│ └── tests/
├── core/src/core/ui/time/
│ ├── time-core.ts # TimeCore class (shared logic)
│ └── tests/
├── react/src/ui/time/
│ ├── index.ts # export * as Time from './index.parts'
│ ├── index.parts.ts # export { Value, Group, Separator }
│ ├── time-value.tsx # Value component
│ ├── time-group.tsx # Group component
│ ├── time-separator.tsx # Separator component
│ ├── time-context.tsx # Context for Group → Value (future)
│ └── tests/
└── html/src/ui/time/
├── time-element.ts # <media-time>
├── time-group-element.ts # <media-time-group>
├── time-separator-element.ts
└── tests/
Core Class
Platform-agnostic formatting logic:
class TimeCore {
setProps(props: TimeCoreProps): void;
getText(time: TimeState): string; // Formatted display "1:30"
getPhrase(time: TimeState): string; // Human-readable "1 minute, 30 seconds"
getDatetime(time: TimeState): string; // ISO 8601 "PT1M30S"
getLabel(): string; // aria-label
getAttrs(time: TimeState): ElementProps; // aria-label, aria-valuetext
getState(time: TimeState): TimeValueState; // State for styling
}
Formatting Utilities
New utilities in @videojs/utils/time:
/**
* Format seconds to digital display string.
* Uses guide (typically duration) to determine hour display.
*/
function formatTime(seconds: number, guide?: number): string;
// 90 → "1:30"
// 3661 → "1:01:01"
/**
* Format seconds to human-readable phrase for screen readers.
*/
function formatTimeAsPhrase(seconds: number): string;
// 90 → "1 minute, 30 seconds"
// -270 → "4 minutes, 30 seconds remaining"
/**
* Convert seconds to ISO 8601 duration for datetime attribute.
*/
function secondsToIsoDuration(seconds: number): string;
// 90 → "PT1M30S"
Reference: Media Chrome time utilities
Context Pattern (Future)
Group can provide shared options via context (e.g., hoursDisplay when added):
// time-context.tsx
const TimeContext = createContext<TimeContextValue | null>(null);
function useTimeContext(): TimeContextValue | null {
return useContext(TimeContext);
}
// time-value.tsx
function Value(props: ValueProps) {
const context = useTimeContext();
const mergedProps = { ...context, ...props }; // Props override context
// ...
}
For HTML, use Lit context (@lit/context) for Group → child communication.
Decisions
Single Value Component with Type Prop
Decision: One Time.Value component with type prop, not separate Time.Current, Time.Duration, Time.Remaining.
Alternatives:
- Separate components — more explicit, but fragments the API
- Base component with aliases — extra exports without clear benefit
Rationale: Single component is simpler to learn. Type safety via type prop guides usage.
Minimal Props for Beta
Decision: Only essential props: type, negativeSign, label. Defer hoursDisplay and fallback to later releases.
Alternatives:
- Per-unit formatting (
hours,minutes,secondsprops) — more control - Multiple format presets (
digital,short,narrow,long) — more options
Rationale: Video players have used the same digital format for decades. Smart defaults handle padding and hour display. We can add options later if users request them.
Smart Padding Defaults
Decision: Automatic padding based on context — hours never padded, minutes padded when hours shown, seconds always padded.
Alternatives:
- Explicit
leadingZerosboolean — simpler but less flexible - Per-unit
hours: "numeric" | "2-digit"props — matches Intl but adds complexity
Rationale: This matches every major video player's behavior. Users rarely need to customize padding.
aria-valuetext for Human-Readable Time
Decision: Use aria-valuetext with human phrase ("1 minute, 30 seconds"), following Media Chrome's pattern.
Alternatives:
- Visually hidden text — clutters DOM
aria-labelwith full value — replaces visible content entirely
Rationale: aria-valuetext is purpose-built for providing alternative text representations. Keeps DOM clean while giving screen readers proper context.
HTML Renders Text Directly
Decision: <media-time> renders text content directly, not inside a <time> element.
Alternatives:
- Render
<time>inside — semantic but creates styling complexity - Shadow DOM with
<time>— encapsulated but harder to style
Rationale: Custom elements can't extend built-in elements reliably. Nesting creates media-time > time selector complexity. aria-valuetext provides equivalent accessibility without the semantic <time>.
Group vs Root Naming
Decision: Time.Group / <media-time-group> for container, not Time.Root.
Alternatives:
Time.Root— matches Radix/Base UITime.Container— verbose
Rationale: "Group" better describes the purpose (grouping time displays). <media-time> is the primary component; "group" clearly indicates it's optional for composition.
Separator as Custom Element
Decision: <media-time-separator> is a custom element, not just a documented pattern.
Alternatives:
- Document "use
<span aria-hidden='true'>/</span>" — simpler - No separator component — leave entirely to users
Rationale: Consistency with component model. Automatic aria-hidden prevents accessibility mistakes. Can have default styling. Low implementation cost.
negativeSign as String
Decision: negativeSign is a string (the symbol to display), not an enum like 'auto' | 'always' | 'never'.
Alternatives:
signDisplayenum — matchesIntl.NumberFormatbut adds complexity- Boolean
showSign— doesn't allow custom symbols
Rationale: Users who want a different symbol (like proper minus −) can specify it. Users who want to hide it can use CSS — the sign is wrapped in <span aria-hidden="true"> which is a natural styling target.
Negative Sign Internal to Value
Decision: The negative sign for remaining time is rendered internally by <Time.Value>, controlled by negativeSign prop.
Alternatives:
- Separate
<Time.Sign>component — maximum flexibility but overkill - User's responsibility — minimal API but users must handle a11y
Rationale: The negative sign is semantically part of "remaining time", not a compositional element like Separator. Internal rendering ensures consistent aria-hidden handling.
Open Questions
-
Locale support — Should we support internationalized phrases via
Intl.DurationFormat? Currently English-only. Could addlocaleprop later. -
Toggle behavior — Media Chrome allows clicking to toggle between current/remaining. Should we support this? Probably a separate concern (wrap in a button).