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.
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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.10Then 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.10Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/media-tileIts 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.10import { 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
Avatarinstead for a person without live media, andAspectRatiofor plain media.
Props
Type: MediaTileProps.
actions- Type:
ReactNode - The tile's own controls, such as a
Menuwith 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 owndata-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 passsize. 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 arechildren. 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-ringfor the ring's colour. connection- Type:
"good" | "fair" | "poor" - Default:
"good" - How well the person's media is getting through.
poorshows a glyph in the top start corner and is read as "poor connection";goodandfairshow nothing, since a fair connection still carries the call. fit- Type:
"cover" | "contain" - How the picture fills the tile:
covercrops it to fill (a camera),containshows all of it with bars (a share). Defaults tocoverfor a camera andcontainfor 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
devicePixelRatiofor 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";truesays "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, orscreenfor 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 usesmuted:<MediaTile muted>.label: Not a prop. Ouro UI usesname:<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 usesfit:<MediaTile fit="contain">.src: Not a prop. Pass the picture as children, and the photo asavatar:<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">;