# Aspect Ratio

> Holds children at a fixed shape, from 16 / 9 or "4:3". Optional hairline frame with a lit corner tick.

Category: Layout. Docs: https://neonkit.heyaadi.com/components/aspect-ratio

## Installation

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

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/aspect-ratio`.

npm dependencies: none. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

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

<AspectRatio ratio={16 / 9}>
  <img src="/talk.jpg" alt="Main stage" className="h-full w-full object-cover" />
</AspectRatio>
```

## Examples

### Video with a frame

frame draws a hairline with a lit corner tick. The player inside sizes itself in cqw, so it scales with the box.

```tsx
"use client";

import * as React from "react";
import { Pause, Play } from "@/components/ui/icons";
import { AspectRatio } from "@/components/ui/aspect-ratio";

const LENGTH = 24 * 60 + 18;

function clock(seconds: number) {
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60);
  return `${m}:${String(s).padStart(2, "0")}`;
}

export default function AspectRatioDemo() {
  const [playing, setPlaying] = React.useState(false);
  const [position, setPosition] = React.useState(9 * 60 + 41);

  React.useEffect(() => {
    if (!playing) return;
    const id = window.setInterval(() => setPosition((p) => (p + 1 >= LENGTH ? 0 : p + 1)), 1000);
    return () => window.clearInterval(id);
  }, [playing]);

  return (
    <figure className="w-full max-w-2xl">
      <AspectRatio ratio={16 / 9} frame className="bg-[#07080d] [container-type:inline-size]">
        {/* A stand-in poster drawn in CSS: the speaker's slide, with the camera feed picture-in-picture. */}
        <div aria-hidden className="absolute inset-0 grid grid-cols-[1.1fr_1fr] items-center gap-[6%] bg-[#0a0c13] px-[8%] pb-[12%] pt-[8%]">
          <div>
            <p className="text-[1.7cqw] text-white/45">Config 2026, main stage</p>
            <p className="mt-[3%] text-[4.4cqw] font-semibold leading-[1.02] tracking-[-0.03em] text-white/90">
              One variable,
              <br />
              every component
            </p>
          </div>
          <div className="flex h-[48%] items-end gap-[5%] border-b border-white/10">
            {[22, 30, 41, 38, 55, 63, 71, 96].map((h, i) => (
              <span
                key={i}
                style={{ height: `${h}%` }}
                className={i === 7 ? "flex-1 rounded-t-[2px] bg-[rgb(var(--neon))]" : "flex-1 rounded-t-[2px] bg-[rgb(var(--neon)/0.22)]"}
              />
            ))}
          </div>
        </div>
        <div aria-hidden className="absolute bottom-[17%] right-[4%] grid aspect-square w-[11%] place-items-center rounded-full border border-white/10 bg-[radial-gradient(circle_at_50%_35%,#2a3142,#141824)] text-[2cqw] font-medium text-white/70">
          MK
        </div>

        <button
          type="button"
          onClick={() => setPlaying((p) => !p)}
          aria-label={playing ? "Pause" : "Play"}
          className="absolute inset-0 grid cursor-pointer place-items-center outline-none [&:focus-visible>span]:ring-2 [&:focus-visible>span]:ring-[rgb(var(--neon)/0.7)]"
        >
          <span
            className={
              playing
                ? "grid size-12 place-items-center rounded-full bg-black/50 text-white/90 opacity-0 transition-opacity duration-200 hover:opacity-100"
                : "grid size-14 place-items-center rounded-full border border-white/15 bg-black/60 text-white backdrop-blur-md transition-transform duration-150 active:scale-[0.96]"
            }
          >
            {playing ? <Pause aria-hidden className="size-5" /> : <Play aria-hidden className="ml-0.5 size-6" />}
          </span>
        </button>

        <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center gap-3 bg-[linear-gradient(0deg,rgb(0_0_0/0.7),transparent)] px-4 pb-3 pt-8">
          <span className="relative h-[3px] flex-1 rounded-full bg-white/15">
            <span
              style={{ width: `${(position / LENGTH) * 100}%` }}
              className="absolute inset-y-0 left-0 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_8px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))]"
            />
          </span>
          <span className="font-mono text-[11.5px] tabular-nums text-white/75">
            {clock(position)} / {clock(LENGTH)}
          </span>
        </div>
      </AspectRatio>
      <figcaption className="mt-3 flex items-baseline justify-between gap-4 text-[13px]">
        <span className="text-white/85">Shipping a design system in 30 days</span>
        <span className="shrink-0 text-white/50">Main stage, 16:9</span>
      </figcaption>
    </figure>
  );
}
```

### Common shapes

ratio takes a number or a string: 1, 16 / 9, "4:5" or "9/16".

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

const shapes = [
  { ratio: "1:1", use: "Avatar, app icon", width: "w-[22%]" },
  { ratio: "4:5", use: "Instagram post", width: "w-[20%]" },
  { ratio: "16:9", use: "Video, OG image", width: "w-[34%]" },
  { ratio: "9:16", use: "Story, Reel", width: "w-[13%]" },
];

export default function AspectRatioRatios() {
  return (
    <div className="flex w-full max-w-3xl items-end gap-[3%]">
      {shapes.map((shape) => (
        <figure key={shape.ratio} className={`${shape.width} shrink-0`}>
          <AspectRatio
            ratio={shape.ratio}
            className="rounded-lg border border-white/10 bg-[repeating-linear-gradient(135deg,rgb(255_255_255/0.035)_0_1px,transparent_1px_7px)]"
          >
            <span className="absolute inset-0 grid place-items-center font-mono text-[12px] tabular-nums text-white/70">
              {shape.ratio}
            </span>
          </AspectRatio>
          <figcaption className="mt-2 text-[12.5px] leading-snug text-white/50">{shape.use}</figcaption>
        </figure>
      ))}
    </div>
  );
}
```

### Lit corner as selection

The tick marks the picked tile. Inside each, a second AspectRatio letterboxes the screenshot at its real shape; portrait ones are sized by height with h-full w-auto.

```tsx
"use client";

import * as React from "react";
import { AspectRatio } from "@/components/ui/aspect-ratio";

const shots = [
  { file: "hero-1440.png", w: 2880, h: 1800 },
  { file: "hero-768.png", w: 1536, h: 2048 },
  { file: "hero-390.png", w: 780, h: 1688 },
  { file: "og-image.png", w: 1200, h: 630 },
];

/** The lit corner marks the selected tile. */
export default function AspectRatioSelection() {
  const [selected, setSelected] = React.useState(0);
  return (
    <div role="group" aria-label="Screenshots" className="grid w-full max-w-2xl grid-cols-2 gap-3 sm:gap-4">
      {shots.map((shot, i) => (
        <button
          key={shot.file}
          type="button"
          aria-pressed={selected === i}
          onClick={() => setSelected(i)}
          className="group cursor-pointer rounded-xl text-left outline-none focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#05060a]"
        >
          <AspectRatio
            ratio={16 / 10}
            frame={selected === i}
            className="rounded-xl border border-white/10 bg-[#0a0c12] transition-colors group-hover:border-white/20"
          >
            {/* Each screenshot letterboxed at its own shape: a second AspectRatio, sized by height when it's taller. */}
            <span className="absolute inset-[8%] flex items-center justify-center">
              <AspectRatio
                ratio={shot.w / shot.h}
                className={
                  shot.w / shot.h < 1.6
                    ? "h-full w-auto rounded-md border border-white/[0.08] bg-white/[0.03]"
                    : "rounded-md border border-white/[0.08] bg-white/[0.03]"
                }
              >
                <span aria-hidden className="absolute inset-[10%] grid grid-rows-[auto_1fr] gap-[8%]">
                  <span className="h-1 w-1/2 rounded-full bg-white/[0.12]" />
                  <span className="rounded-[3px] bg-[rgb(var(--neon)/0.16)]" />
                </span>
              </AspectRatio>
            </span>
          </AspectRatio>
          <span className="mt-2 flex flex-col gap-0.5 text-[12.5px] sm:flex-row sm:items-baseline sm:justify-between sm:gap-2">
            <span className="truncate font-mono text-white/80">{shot.file}</span>
            <span className="shrink-0 tabular-nums text-white/45">
              {shot.w} × {shot.h}
            </span>
          </span>
        </button>
      ))}
    </div>
  );
}
```

## API reference

### AspectRatio

Renders a div and accepts its attributes; the ref points at it. Children fill an inner box clipped to the corners.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `ratio` | `number \| string` | `1` | Width over height. Strings like "16:9" or "21/9" work too. Anything invalid falls back to 1. |
| `frame` | `boolean \| "top-left" \| "top-right" \| "bottom-right" \| "bottom-left"` | `false` | Adds a 12px radius, a white/10 hairline and a lit tick on one corner. true puts it top left. |
| `color` | `NeonColor` | `inherited` | Color of the tick. Falls back to --neon-accent, then cyan. |

## Accessibility

- It uses the CSS aspect-ratio property. Set a width (it fills its parent by default) or, for a box sized by height, h-full w-auto.
- Children are positioned inside an absolute box, so media should be h-full w-full with object-cover or object-contain.
- The frame is decoration and is hidden from assistive tech. Give images their own alt text.

## Source

`components/ui/aspect-ratio.tsx`

```tsx
import * as React from "react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

type Corner = "top-left" | "top-right" | "bottom-right" | "bottom-left";

// The tick sits on the hairline itself (-1px), follows the corner's curve, and fades along both arms.
const ticks: Record<Corner, string> = {
  "top-left": "-left-px -top-px rounded-tl-[inherit] border-l-[1.5px] border-t-[1.5px] [mask-image:radial-gradient(circle_at_0_0,#000_45%,transparent_75%)]",
  "top-right": "-right-px -top-px rounded-tr-[inherit] border-r-[1.5px] border-t-[1.5px] [mask-image:radial-gradient(circle_at_100%_0,#000_45%,transparent_75%)]",
  "bottom-right": "-bottom-px -right-px rounded-br-[inherit] border-b-[1.5px] border-r-[1.5px] [mask-image:radial-gradient(circle_at_100%_100%,#000_45%,transparent_75%)]",
  "bottom-left": "-bottom-px -left-px rounded-bl-[inherit] border-b-[1.5px] border-l-[1.5px] [mask-image:radial-gradient(circle_at_0_100%,#000_45%,transparent_75%)]",
};

function toRatio(ratio: number | string) {
  if (typeof ratio === "number") return Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
  const [w, h] = ratio.split(/[/:]/).map((part) => Number.parseFloat(part));
  return w > 0 && h > 0 ? w / h : 1;
}

export interface AspectRatioProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
  /** Width over height: `16 / 9`, `1`, or a string like `"4:3"` or `"21/9"`. */
  ratio?: number | string;
  /** Draws a hairline around the box with a lit tick on one corner. `true` puts it top left. */
  frame?: boolean | Corner;
  /** Palette name or hex for the tick. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
}

/** Holds its children at a fixed shape. They fill it, so give media `h-full w-full object-cover`. */
const AspectRatio = React.forwardRef<HTMLDivElement, AspectRatioProps>(
  ({ ratio = 1, frame = false, color, className, style, children, ...props }, ref) => {
    const corner: Corner | null = frame === true ? "top-left" : frame || null;
    return (
      <div
        ref={ref}
        data-ratio={toRatio(ratio).toFixed(4)}
        style={neonVars(color, { aspectRatio: String(toRatio(ratio)), ...style })}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "relative w-full",
          corner && "rounded-xl border border-white/10",
          className,
        )}
        {...props}
      >
        <div className="absolute inset-0 overflow-hidden rounded-[inherit]">{children}</div>
        {corner && (
          <span
            aria-hidden
            data-corner={corner}
            className={cn(
              "pointer-events-none absolute size-10 border-[rgb(var(--neon))]",
              "[filter:drop-shadow(0_0_2px_rgb(var(--neon)/CALC(0.9*var(--neon-glow,1))))_drop-shadow(0_0_8px_rgb(var(--neon)/CALC(0.5*var(--neon-glow,1))))]",
              ticks[corner],
            )}
          />
        )}
      </div>
    );
  },
);
AspectRatio.displayName = "AspectRatio";

export { AspectRatio };
```
