Skip to main content

Display

MediaTile

A tile for one live picture in a call: a person's camera, their photo when the camera is off, or a shared screen. Pass your <video> as children; MediaTile renders no media itself. Over the picture it draws a name plate in the bottom start corner, with a crossed-out microphone while the person is muted and "Presenting" after the name, a raised hand and a poor-connection glyph in the top start corner, and the speaking ring.

Mara's tile: measuring

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/media-tile#v1.3.10

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/media-tile

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/media-tile#v1.3.10 ourostack/ouro-ui/media-tile-tests#v1.3.10
import { MediaTile } from "@/components/ouro/media-tile/media-tile";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

<MediaTile name="Mara Lind" speaking={isSpeaking} muted={isMuted} avatar={{ src: photoUrl }} onVisibilityChange={subscribe}>
  <video ref={attachStream} autoPlay playsInline muted />
</MediaTile>
<MediaTile name="Theo Okafor" source="screen" presenting ring={false}>
  <video ref={attachShare} autoPlay playsInline muted />
</MediaTile>

Usage

The tile reads as one sentence: "Mara Lind, speaking, camera on, hand raised". It is a group with that name, and nothing in it is read again. The ring is an inset box-shadow over the picture, so it never moves the layout. It has two states, speaking or not, held for hysteresis and eased between over --ouro-duration-fast; it never throbs with the voice. With reduced motion it is steady on or off, and under forced colours it is an outline.

The name plate sits on a solid translucent fill with no backdrop blur, which would repaint over moving video every frame; with reduced transparency or more contrast the fill is solid. A name too long for the plate is cut short, with the whole name in its tooltip.

onVisibilityChange tells you whether the tile can be seen and its size, from IntersectionObserver and ResizeObserver, once a frame and only when something changed, so you can stop receiving pictures nobody sees and ask for the smallest size at or above the tile's.

Its agent side, mediaTileAx in media-tile-styles.ts, declares one read, participant (name, muted, camera, speaking, raised hand and connection, which anyone may read, since the tile shows them to everyone in the call), and one action, pin, which only changes the person's own view, so an agent may take it. The Pin toggle carries data-ax-action="pin".

Use instead when

  • Use Avatar instead for a person without live media, and AspectRatio for plain media.

Props

Type: MediaTileProps.

actions
Type: ReactNode
The tile's own controls, such as a Menu with Pin, Spotlight and Remove, in the top end corner after the Pin toggle. They show while the pointer is over the tile or focus is in it, and always on a touch screen. Give a Pin item of your own data-ax-action="pin".
avatar
Type: MediaTileAvatar
The person's photo, shown in place of the picture: Avatar's props, such as { src, idForColor }. Without it the photo is their initials. It grows with the tile unless you pass size.
cameraOn
Type: boolean
Whether the person's camera is on. While it is off the tile shows their photo, or their initials, from avatar. Defaults to whether there are children.
children
Type: ReactNode
The picture: your <video> (or <canvas>, or <img>), which fills the tile. MediaTile renders no media of its own, and keeps yours mounted while it shows the photo instead, so a <video> keeps its stream when the camera goes off and on.
className
Type: string
Layout only: width, grid placement, another shape (aspect-square, h-full). The tile is 16:9 and as wide as its container by default. Appearance classes warn in development; set --ouro-media-tile-ring for the ring's colour.
connection
Type: "good" | "fair" | "poor"
Default: "good"
How well the person's media is getting through. poor shows a glyph in the top start corner and is read as "poor connection"; good and fair show nothing, since a fair connection still carries the call.
fit
Type: "cover" | "contain"
How the picture fills the tile: cover crops it to fill (a camera), contain shows all of it with bars (a share). Defaults to cover for a camera and contain for a screen.
handRaised
Type: boolean
Whether the person's hand is up: a hand in the top start corner, and "hand raised" in the sentence.
hysteresis
Type: MediaTileHysteresis
Default: { on: 300, off: 600 }
How long, in milliseconds, someone must speak before the ring comes on (on), and be quiet before it goes (off). The ring eases between the two states and never throbs with the voice.
labels
Type: Partial<MediaTileLabels>
The words the tile is read in, and its controls' names. Pass the ones you translate; the rest stay English.
muted
Type: boolean
Whether the person is muted: a crossed-out microphone on the name plate, and "muted" in the sentence.
name (required)
Type: string
Who is in the tile: "Mara Lind". The name plate shows it, its photo's initials come from it, and it starts the sentence screen readers hear. Required, so no tile is silent.
onPinnedChange
Type: ((pinned: boolean) => void)
Shows a Pin toggle in the tile's top end corner, with data-ax-action="pin", and is called with the new state when it is pressed. Pinning changes only the person's own view.
onVisibilityChange
Type: ((visible: boolean, width: number, height: number) => void)
Called with whether the tile can be seen and its size in CSS pixels, rounded up, whenever either changes, so you can stop receiving a picture nobody sees and ask for a picture size at or above the tile's (multiply by devicePixelRatio for device pixels). A tile counts as seen once a tenth of it is on screen, so a sliver doesn't count, and not while the page is hidden. The first call waits until the browser has said whether the tile is on screen. Changes are gathered once a frame, and it is never called twice with the same values.
paused
Type: boolean | string
The picture is paused (your app stopped receiving it, to save bandwidth or power): the tile shows the photo with a pause glyph. Pass the reason as words to say it, such as paused="video paused to save power"; true says "video paused".
pinned
Type: boolean
Whether the tile is pinned: a pin on the name plate (or the Pin control pressed), and "pinned" in the sentence.
presenting
Type: boolean
Whether the person is presenting: "Presenting" after the name on the name plate, and in the sentence.
ref
Type: Ref<HTMLDivElement>
Reaches the rendered element.
ring
Type: boolean
Default: true
Whether to draw the speaking ring. Pass ring={false} for a stage tile, which already says who is speaking; the sentence still says "speaking".
source
Type: "camera" | "screen"
Default: "camera"
Where the picture comes from: camera, or screen for a shared screen or window, which is read as "shared screen" and is never cropped.
speaking
Type: boolean
Whether the person is speaking now: the app's speaker detection. The ring and the sentence follow it after hysteresis, so a cough doesn't flash the ring.

Native attributes

Also accepts the attributes in React's HTMLAttributes (id, aria-*, event handlers), passed to the rendered element, except those named on this page.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • isMuted: Not a prop. Ouro UI uses muted: <MediaTile muted>.
  • label: Not a prop. Ouro UI uses name: <MediaTile name="Mara Lind">.
  • level: Not a prop. The ring is on or off, from your speaker detection: <MediaTile speaking={level > 0.1}>.
  • objectFit: Not a prop. Ouro UI uses fit: <MediaTile fit="contain">.
  • src: Not a prop. Pass the picture as children, and the photo as avatar: <MediaTile avatar={{ src: photoUrl }}>.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

AvatarColor

Where the initials' colors come from: a hue picked from the name, the theme's primary, or the neutral surface.

type AvatarColor = "colorful" | "brand" | "neutral";

AvatarNoPresenceProps

An Avatar without a presence dot.

interface AvatarNoPresenceProps {
  /** Leave it out for no dot; pass it with `presenceLabel` to show one. */
  presence?: undefined;
  /** Read only with `presence`. */
  presenceLabel?: string | undefined;
}

AvatarOwnProps

The props Avatar adds to the <span> attributes it passes through.

interface AvatarOwnProps {
  /**
   * The person's or thing's name. Screen readers hear it, and the initials come from it
   * (the first letters of the first and last words) until the image loads, or when it
   * fails. Required, so no avatar is silent. Pass `null` while the name is still loading: the
   * avatar keeps its size and shape and shimmers as a Skeleton, and screen readers hear
   * `nameLoadingLabel`.
   */
  name: string | null;
  /** What screen readers hear while `name` is `null`, for other languages. @default "Name loading" */
  nameLoadingLabel?: string | undefined;
  /** The image URL. Until it loads, and if it fails, the initials show. */
  src?: string | undefined;
  /**
   * What screen readers hear, when it should differ from `name`. Pass `alt=""` when the
   * name is already written next to the avatar, so it isn't read twice.
   */
  alt?: string | undefined;
  /**
   * The letters to show instead of the ones taken from `name`, such as "AnL". Like the initials
   * taken from `name`, they are drawn (CSS generated content), not written as text.
   */
  initials?: string | undefined;
  /**
   * An icon to show in place of the initials, such as a calendar for a meeting or a note for
   * notes to self. It is decorative: the avatar's label stays `name`.
   */
  icon?: ReactNode | undefined;
  /**
   * `"circle"` for a person, `"square"` (rounded on the theme's radius) for a team, a
   * channel, an app or a bot, `"hexagon"` (with rounded corners) for an AI agent, as Teams
   * draws agents. The initials, icon or picture fill the shape, centered. A hexagon shows no
   * presence dot: an agent has no presence. @default "circle"
   */
  shape?: AvatarShape | (string & { 'Use "circle", "square" or "hexagon"; rounded is "square", an agent "hexagon"': never }) | undefined;
  /**
   * How the initials are colored. `"colorful"` picks one of eight hues from `idForColor`
   * (else `name`), so the same person always gets the same one, and the initials keep at
   * least 4.5:1 against it in light and dark. `"brand"` tints with the theme's primary;
   * `"neutral"` is the plain secondary surface. The hues are the theme tokens `--avatar-1`
   * to `--avatar-8` (`ouro-base`). @default "colorful"
   */
  color?: AvatarColor | (string & { 'Use "colorful", "brand" or "neutral"': never }) | undefined;
  /**
   * What `color="colorful"` picks the hue from, when it should not be `name`: a stable id,
   * so people who share a name can differ and a rename keeps the color.
   */
  idForColor?: string | undefined;
  /**
   * 24, 32, 40, 48, 64, 72 or 112px (`2xl` and `3xl` for profile pages); the initials and
   * the presence dot grow with it. Inside an `AvatarGroup`, the group's size applies.
   * @default "md"
   */
  size?: AvatarSize | (string & { 'Use "xs", "sm", "md", "lg", "xl", "2xl" or "3xl"': never }) | undefined;
  /** Layout only: margins, grid placement (`ms-2`). Appearance classes warn in development; use `size`. */
  className?: string | undefined;
  ref?: Ref<HTMLSpanElement> | undefined;

  /** Not a prop. The initials come from `name`, or pass `initials`: `<Avatar name="Ana Lind" initials="AL" />`. */
  children?: { 'Use <Avatar name="Ana Lind" initials="AL" />': never };
  /** Not a prop. The initials come from `name`, or pass `initials`: `<Avatar name="Ana Lind" initials="AL" />`. */
  fallback?: { 'Use <Avatar name="Ana Lind" initials="AL" />': never };
}

AvatarPresence

A person's presence, drawn as a dot with its own shape: a check (available), a solid dot (busy), a bar (do not disturb), a clock (away), a ring with a cross (offline) and a ring with an arrow (out of office), so it never relies on color alone.

type AvatarPresence = "available" | "busy" | "away" | "do-not-disturb" | "offline" | "out-of-office";

AvatarPresenceProps

An Avatar with a presence dot and the words it says.

interface AvatarPresenceProps {
  /**
   * The person's presence, as a dot on the lower end corner. Each state has its own shape
   * and a named tone (available: success; busy and do not disturb: destructive; away:
   * warning; offline: muted; out of office: primary). The dot's ring takes the surface's
   * color, `--ouro-avatar-ring` (default `--background`): set it on a card,
   * `[--ouro-avatar-ring:var(--card)]`. The dot lies over the corner and takes no room, so
   * the initials, icon or picture stay in the middle with or without it, and the dot stays
   * below the letters at every size.
   */
  presence: AvatarPresence | (string & { 'dnd is "do-not-disturb"; ooo is "out-of-office"': never });
  /**
   * What the dot says, such as "Busy" or "In a meeting": screen readers hear it after the
   * name ("Ana Lind, Busy"). Required with `presence`, so no dot is silent.
   */
  presenceLabel: string;
}

AvatarProps

Props for Avatar: its own props, presence with presenceLabel or neither, and the <span> attributes it passes through.

type AvatarProps = Omit<HTMLAttributes<HTMLSpanElement>, keyof AvatarOwnProps | "presence" | "presenceLabel" | "role"> &
  AvatarOwnProps &
  (AvatarPresenceProps | AvatarNoPresenceProps);

AvatarShape

Avatar's shapes: a circle for a person, a rounded square for a team, a channel, an app or a bot, and a rounded hexagon for an AI agent.

type AvatarShape = "circle" | "square" | "hexagon";

AvatarSize

Avatar sizes: 24, 32, 40, 48, 64, 72 and 112px.

type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl";

MediaTileAvatar

The Avatar props a tile passes to the photo it shows in place of the picture. Its name is the tile's.

type MediaTileAvatar = Pick<AvatarOwnProps, "src" | "initials" | "icon" | "shape" | "color" | "idForColor" | "size">;