# Aspect ratio

> A box that holds its shape before media loads, shimmers while loading, fades the media in and falls back when it fails.

Docs: https://hextaui.com/docs/aspect-ratio
Markdown: https://hextaui.com/docs/aspect-ratio.md

```tsx title="components/examples/aspect-ratio/demo.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioDemo() {
  return (
    <div className="w-full max-w-md">
      <AspectRatio ratio={16 / 9} className="rounded-xl">
        <img src="/preview/landscape.svg" alt="Sunset over mountains" />
      </AspectRatio>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/aspect-ratio.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react @tabler/icons-react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/aspect-ratio.tsx"
"use client"

import * as React from "react"
import { flushSync } from "react-dom"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { IconPhotoOff } from "@tabler/icons-react"
import { cn } from "cn"

import { Skeleton } from "@/components/ui/skeleton"

type AspectRatioValue = number | `${number}/${number}` | `${number}:${number}`

type MediaState = "loading" | "loaded" | "error"

type AspectRatioProps = useRender.ComponentProps<"div"> & {
  ratio?: AspectRatioValue
  placeholder?: boolean
  fallback?: React.ReactNode
}

const mediaSelector = ":scope > img, :scope > picture > img, :scope > video"

function parseAspectRatio(ratio: AspectRatioValue | undefined) {
  if (ratio === undefined) {
    return 1
  }

  let value = Number.NaN

  if (typeof ratio === "number") {
    value = ratio
  } else {
    const trimmed = ratio.trim()
    const pair = trimmed.match(/^(\d*\.?\d+)\s*[/:]\s*(\d*\.?\d+)$/)
    if (pair) {
      value = Number(pair[1]) / Number(pair[2])
    } else if (/^\d*\.?\d+$/.test(trimmed)) {
      value = Number(trimmed)
    }
  }

  if (!Number.isFinite(value) || value <= 0) {
    if (process.env.NODE_ENV !== "production") {
      console.warn(
        `AspectRatio: invalid ratio ${JSON.stringify(ratio)}, falling back to 1.`
      )
    }
    return 1
  }

  return value
}

function readMediaState(media: Element): MediaState {
  if (media instanceof HTMLImageElement) {
    if (!media.getAttribute("src") && !media.getAttribute("srcset")) {
      return "loading"
    }
    if (!media.complete) {
      return "loading"
    }
    return media.naturalWidth > 0 ? "loaded" : "error"
  }
  if (media instanceof HTMLVideoElement) {
    if (
      media.error ||
      media.networkState === HTMLMediaElement.NETWORK_NO_SOURCE
    ) {
      return "error"
    }
    return media.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA
      ? "loaded"
      : "loading"
  }
  return "loaded"
}

function useMediaState(
  containerRef: React.RefObject<HTMLElement | null>,
  enabled: boolean
) {
  const [state, setState] = React.useState<MediaState | null>(null)

  React.useLayoutEffect(() => {
    const container = containerRef.current
    if (!enabled || !container) {
      return
    }

    const findMedia = () => container.querySelector(mediaSelector)

    const sync = () => {
      const media = findMedia()
      setState(media ? readMediaState(media) : null)
    }

    const isOwn = (target: EventTarget | null) => {
      const media = findMedia()
      return (
        media !== null &&
        (target === media ||
          (target instanceof HTMLSourceElement &&
            target.parentElement === media))
      )
    }

    const handleLoad = (event: Event) => {
      if (isOwn(event.target)) {
        setState("loaded")
      }
    }

    const handleError = (event: Event) => {
      if (isOwn(event.target)) {
        setState("error")
      }
    }

    container.addEventListener("load", handleLoad, true)
    container.addEventListener("loadeddata", handleLoad, true)
    container.addEventListener("error", handleError, true)

    const observer = new MutationObserver(() => flushSync(sync))
    observer.observe(container, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ["src", "srcset"],
    })

    sync()

    return () => {
      container.removeEventListener("load", handleLoad, true)
      container.removeEventListener("loadeddata", handleLoad, true)
      container.removeEventListener("error", handleError, true)
      observer.disconnect()
      setState(null)
    }
  }, [containerRef, enabled])

  return enabled ? state : null
}

function AspectRatio({
  ratio,
  placeholder = true,
  fallback = <IconPhotoOff />,
  className,
  style,
  render,
  ref,
  children,
  ...props
}: AspectRatioProps) {
  const containerRef = React.useRef<HTMLDivElement>(null)
  const value = React.useMemo(() => parseAspectRatio(ratio), [ratio])
  const state = useMediaState(containerRef, placeholder)

  return useRender({
    defaultTagName: "div",
    render,
    ref: ref ? [containerRef, ref] : containerRef,
    props: mergeProps<"div">(
      {
        ...({ "data-slot": "aspect-ratio" } as Record<string, string>),
        className: cn(
          "relative aspect-(--ratio) w-full [&>:is(img,video)]:object-cover [&>:is(img,video,iframe,picture)]:absolute [&>:is(img,video,iframe,picture)]:inset-0 [&>:is(img,video,iframe,picture)]:size-full [&>:is(img,video,iframe,picture)]:rounded-[inherit] [&>picture>img]:size-full [&>picture>img]:rounded-[inherit] [&>picture>img]:object-cover",
          "[&>:is(img,video,picture)]:transition-opacity [&>:is(img,video,picture)]:duration-300 [&>:is(img,video,picture)]:ease-out-quint data-[state=error]:[&>:is(img,video,picture)]:opacity-0 data-[state=loading]:[&>:is(img,video,picture)]:opacity-0 motion-reduce:[&>:is(img,video,picture)]:transition-none",
          className
        ),
        style: { ...style, "--ratio": value } as React.CSSProperties,
        children: (
          <>
            {state === "loading" || state === "error" ? (
              <Skeleton
                data-slot="aspect-ratio-placeholder"
                animation={state === "loading" ? "shimmer" : "none"}
                className="pointer-events-none absolute inset-0 rounded-[inherit]"
              />
            ) : null}
            {children}
            {state === "error" && fallback !== null ? (
              <span
                data-slot="aspect-ratio-fallback"
                aria-hidden="true"
                className="pointer-events-none absolute inset-0 grid animate-in place-items-center rounded-[inherit] text-muted-foreground animation-duration-200 fade-in-0 [&>svg:not([class*='size-'])]:size-6"
              >
                {fallback}
              </span>
            ) : null}
          </>
        ),
        "aria-busy": state === "loading" || undefined,
      },
      props,
      {
        "data-state": state ?? undefined,
      } as Record<string, string | undefined>
    ),
  })
}

export { AspectRatio, parseAspectRatio }
export type { AspectRatioProps, AspectRatioValue }
```

```tsx title="components/ui/skeleton.tsx"
"use client"

import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"

const skeletonVariants = cva(
  "relative overflow-hidden rounded-md bg-muted motion-safe:animate-in motion-safe:animation-duration-300 motion-safe:fade-in-0 motion-safe:fill-mode-backwards motion-safe:[animation-delay:150ms] rtl:[--skeleton-dir:-1] [&:where(span)]:inline-block [&:where(span)]:align-bottom",
  {
    variants: {
      animation: {
        shimmer:
          "after:pointer-events-none after:absolute after:inset-0 after:[translate:-100%_0] after:bg-linear-to-r after:from-transparent after:via-foreground/8 after:to-transparent motion-safe:after:animate-shimmer dark:after:via-foreground/10",
        pulse:
          "after:pointer-events-none after:absolute after:inset-0 after:bg-background after:opacity-0 motion-safe:after:animate-skeleton-pulse",
        none: "",
      },
    },
    defaultVariants: {
      animation: "shimmer",
    },
  }
)

type SkeletonAnimation = NonNullable<
  VariantProps<typeof skeletonVariants>["animation"]
>

type SkeletonProps = useRender.ComponentProps<"div"> & {
  animation?: SkeletonAnimation
  loading?: boolean
}

function useReveal(loading: boolean | undefined) {
  const [previous, setPrevious] = React.useState(loading)
  const [revealed, setRevealed] = React.useState(false)

  if (loading !== previous) {
    setPrevious(loading)
    setRevealed(previous === true && loading === false)
  }

  return revealed
}

function Skeleton({
  className,
  animation = "shimmer",
  loading,
  render,
  children,
  ...props
}: SkeletonProps) {
  const revealed = useReveal(loading)
  const wraps = loading !== undefined
  const showsSkeleton = !wraps || loading

  return useRender({
    defaultTagName: "div",
    render,
    props: mergeProps<"div">(
      {
        ...({ "data-slot": "skeleton" } as Record<string, string>),
        className: cn(
          wraps && "w-fit max-w-full",
          showsSkeleton && skeletonVariants({ animation }),
          wraps &&
            !loading &&
            revealed &&
            "motion-safe:animate-in motion-safe:ease-out-quint motion-safe:animation-duration-200 motion-safe:fade-in-0",
          className
        ),
        "aria-hidden": wraps ? undefined : true,
        "aria-busy": loading || undefined,
        children: wraps ? (
          <span
            data-slot="skeleton-content"
            className={cn("contents", loading && "invisible")}
            inert={loading || undefined}
            aria-hidden={loading || undefined}
          >
            {children}
          </span>
        ) : (
          children
        ),
      },
      props,
      {
        "data-animation": showsSkeleton ? animation : undefined,
        "data-loading": wraps ? String(Boolean(loading)) : undefined,
      } as Record<string, string | undefined>
    ),
  })
}

type SkeletonTextProps = React.ComponentProps<"div"> & {
  lines?: number
  animation?: SkeletonAnimation
}

function SkeletonText({
  className,
  lines = 3,
  animation,
  ...props
}: SkeletonTextProps) {
  const count = Math.min(
    50,
    Math.max(1, Math.floor(Number.isFinite(lines) ? lines : 1))
  )

  return (
    <div
      data-slot="skeleton-text"
      aria-hidden="true"
      className={cn("flex w-full flex-col", className)}
      {...props}
    >
      {Array.from({ length: count }, (_, index) => (
        <div key={index} className="flex h-[1lh] items-center">
          <Skeleton
            animation={animation}
            className={cn(
              "h-[0.8em] rounded-sm",
              count > 1 && index === count - 1 ? "w-3/5" : "w-full"
            )}
          />
        </div>
      ))}
    </div>
  )
}

export { Skeleton, SkeletonText, skeletonVariants }
export type { SkeletonProps, SkeletonTextProps, SkeletonAnimation }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { AspectRatio } from "@/components/ui/aspect-ratio"
```

```tsx
<AspectRatio ratio={16 / 9} className="rounded-lg">
  <img src="/photo.jpg" alt="Sunset over mountains" />
</AspectRatio>
```

## Examples

### Ratios

`ratio` takes a number, a `"w/h"` string or a `"w:h"` string.

```tsx title="components/examples/aspect-ratio/ratios.tsx"
import {
  AspectRatio,
  type AspectRatioValue,
} from "@/components/ui/aspect-ratio"

const ratios: { label: string; ratio: AspectRatioValue }[] = [
  { label: "16 / 9", ratio: 16 / 9 },
  { label: "1", ratio: 1 },
  { label: '"4/3"', ratio: "4/3" },
  { label: '"21:9"', ratio: "21:9" },
]

export function AspectRatioRatios() {
  return (
    <div className="grid w-full max-w-md grid-cols-2 gap-3">
      {ratios.map(({ label, ratio }) => (
        <div key={label} className="flex flex-col gap-1.5">
          <AspectRatio ratio={ratio} className="rounded-lg">
            <img src="/preview/landscape.svg" alt="Sunset over mountains" />
          </AspectRatio>
          <span className="font-mono text-xs text-muted-foreground">
            {label}
          </span>
        </div>
      ))}
    </div>
  )
}
```

### Slow load

The box holds its shape and shimmers until the image arrives, then the image fades in, so nothing below it moves. Press Reload to watch it again.

```tsx title="components/examples/aspect-ratio/slow-load.tsx"
"use client"

import * as React from "react"

import { AspectRatio } from "@/components/ui/aspect-ratio"
import { Button } from "@/components/ui/button"

export function AspectRatioSlowLoad() {
  const [src, setSrc] = React.useState<string | undefined>(undefined)
  const [attempt, setAttempt] = React.useState(0)

  React.useEffect(() => {
    const timer = setTimeout(
      () => setSrc(`/preview/landscape.svg?v=${attempt}`),
      1500
    )
    return () => clearTimeout(timer)
  }, [attempt])

  return (
    <div className="flex w-full max-w-md flex-col gap-3">
      <AspectRatio ratio={16 / 9} className="rounded-xl">
        <img src={src} alt="Sunset over mountains" />
      </AspectRatio>
      <Button
        variant="outline"
        size="sm"
        className="self-start"
        onClick={() => {
          setSrc(undefined)
          setAttempt(attempt + 1)
        }}
      >
        Reload
      </Button>
    </div>
  )
}
```

### Broken image

When the image fails, the browser’s broken-image glyph is hidden and a fallback icon is shown instead. Pass `fallback` to replace it, or `fallback={null}` to show nothing.

```tsx title="components/examples/aspect-ratio/broken-image.tsx"
import { IconMoodSad } from "@tabler/icons-react"

import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioBrokenImage() {
  return (
    <div className="grid w-full max-w-md grid-cols-2 gap-3">
      <AspectRatio ratio={4 / 3} className="rounded-lg">
        <img src="/preview/does-not-exist.jpg" alt="Team photo" />
      </AspectRatio>
      <AspectRatio
        ratio={4 / 3}
        className="rounded-lg"
        fallback={
          <span className="flex flex-col items-center gap-1 text-xs">
            <IconMoodSad />
            Couldn’t load
          </span>
        }
      >
        <img src="/preview/does-not-exist.jpg" alt="Team photo" />
      </AspectRatio>
    </div>
  )
}
```

### Overlay

Absolutely positioned children sit on top of the media. The box clips nothing, so focus rings on overlay links stay visible.

```tsx title="components/examples/aspect-ratio/overlay.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioOverlay() {
  return (
    <div className="w-full max-w-md">
      <AspectRatio ratio={16 / 9} className="rounded-xl">
        <img src="/preview/landscape.svg" alt="Sunset over mountains" />
        <div className="absolute inset-x-3 bottom-3 flex items-center justify-between gap-3 rounded-lg bg-background/80 px-3 py-2 text-sm backdrop-blur-sm">
          <span className="truncate font-medium">Dolomites at dusk</span>
          <a
            href="#"
            className="shrink-0 rounded-sm underline underline-offset-4 outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden"
          >
            View
          </a>
        </div>
      </AspectRatio>
    </div>
  )
}
```

### Without placeholder

`placeholder={false}` turns off the loading shimmer, the fade-in and the fallback, for the plain shadcn behavior.

```tsx title="components/examples/aspect-ratio/without-placeholder.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioWithoutPlaceholder() {
  return (
    <div className="w-full max-w-md">
      <AspectRatio ratio={16 / 9} placeholder={false} className="rounded-lg">
        <img src="/preview/landscape.svg" alt="Sunset over mountains" />
      </AspectRatio>
    </div>
  )
}
```

### Responsive

Override the ratio at a breakpoint with an aspect class. This one is square on small screens and `md:aspect-video` from md up.

```tsx title="components/examples/aspect-ratio/responsive.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioResponsive() {
  return (
    <div className="w-full max-w-md">
      <AspectRatio ratio={1} className="rounded-lg md:aspect-video">
        <img src="/preview/landscape.svg" alt="Sunset over mountains" />
      </AspectRatio>
    </div>
  )
}
```

### Inside a centered flex column

The box is full width by default, so it fills the column instead of collapsing to zero when the parent centers its children.

```tsx title="components/examples/aspect-ratio/flex-column.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioFlexColumn() {
  return (
    <div className="flex w-full max-w-md flex-col items-center rounded-lg border border-dashed p-3">
      <AspectRatio ratio={16 / 9} className="rounded-lg">
        <img src="/preview/landscape.svg" alt="Sunset over mountains" />
      </AspectRatio>
    </div>
  )
}
```

### Text content

Children that aren’t media get the box and nothing else. Position them yourself.

```tsx title="components/examples/aspect-ratio/text-content.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioTextContent() {
  return (
    <div className="w-full max-w-md">
      <AspectRatio ratio={3} className="rounded-lg border">
        <div className="absolute inset-0 grid place-items-center p-4 text-center text-sm text-muted-foreground">
          Any content can sit in the box.
        </div>
      </AspectRatio>
    </div>
  )
}
```

### As a figure

Keep captions outside the box so they don’t change the ratio.

```tsx title="components/examples/aspect-ratio/figure.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioFigure() {
  return (
    <figure className="flex w-full max-w-md flex-col gap-2">
      <AspectRatio ratio={16 / 9} className="rounded-lg">
        <img src="/preview/landscape.svg" alt="Sunset over mountains" />
      </AspectRatio>
      <figcaption className="text-xs text-muted-foreground">
        Dolomites at dusk, photographed from Seceda.
      </figcaption>
    </figure>
  )
}
```

### Invalid ratio

`0`, negative numbers and unparsable strings fall back to a square and log a warning in development.

```tsx title="components/examples/aspect-ratio/invalid-ratio.tsx"
import {
  AspectRatio,
  type AspectRatioValue,
} from "@/components/ui/aspect-ratio"

export function AspectRatioInvalidRatio() {
  return (
    <div className="grid w-full max-w-md grid-cols-2 gap-3">
      <AspectRatio ratio={0} className="rounded-lg border" />
      <AspectRatio
        ratio={"abc" as AspectRatioValue}
        className="rounded-lg border"
      />
    </div>
  )
}
```

### Right to left

Overlays positioned with logical properties like start-3 follow the reading direction.

```tsx title="components/examples/aspect-ratio/rtl.tsx"
import { AspectRatio } from "@/components/ui/aspect-ratio"

export function AspectRatioRtl() {
  return (
    <div dir="rtl" className="w-full max-w-md">
      <AspectRatio ratio={16 / 9} className="rounded-lg">
        <img src="/preview/landscape.svg" alt="غروب الشمس فوق الجبال" />
        <span className="absolute start-3 top-3 rounded-md bg-background/80 px-2 py-1 text-xs">
          جديد
        </span>
      </AspectRatio>
    </div>
  )
}
```

## Accessibility

- The box is `aria-busy` while its media loads.
- The fallback is decorative and hidden from assistive tech. The image’s `alt` text stays available when it fails to load, so always write one.
- With reduced motion on, media appears without fading.

## API reference

Accepts every attribute of the element it renders. Media placed directly inside, an `<img>`, `<picture>` or `<video>`, fills the box with `object-cover` and inherits its radius.

### AspectRatio

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `ratio` | `` number \| `${number}/${number}` \| `${number}:${number}` `` | `1` |  |
| `placeholder` | `boolean` | `true` | Shows a shimmer while the media loads and a fallback when it fails. |
| `fallback` | `ReactNode` | `<IconPhotoOff />` | Shown when the media fails. null shows nothing. |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="aspect-ratio"` | Target the box in CSS. |
| `data-state` | loading, loaded or error. Set only when placeholder is on and the box holds media. |
| `aria-busy` | Present while the media loads. |
| `--ratio` | The parsed ratio as a number. |
| `data-slot="aspect-ratio-placeholder"` | The shimmer shown while loading or after an error. |
| `data-slot="aspect-ratio-fallback"` | The wrapper around the fallback. |

### parseAspectRatio

`parseAspectRatio(ratio)` turns any accepted ratio into a number, falling back to `1`. Use it to size other elements the same way. The `AspectRatioValue` and `AspectRatioProps` types are exported too.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
