diff --git a/apps/tester/src/settings.tsx b/apps/tester/src/settings.tsx
index 3576403..60ab84b 100644
--- a/apps/tester/src/settings.tsx
+++ b/apps/tester/src/settings.tsx
@@ -25,6 +25,7 @@ export type Fixture = {
alterVariants?: UIVariant[];
alterColors?: UIColor[];
alterStates?: UIState[];
+ waitTime?: number;
renderJoyElement: ElementRenderer;
renderTjElement: ElementRenderer;
};
@@ -63,6 +64,7 @@ export function testEach(
alterVariants,
alterColors,
alterStates,
+ waitTime,
renderJoyElement,
renderTjElement,
} of fixtures) {
@@ -112,6 +114,9 @@ export function testEach(
,
);
+ if (waitTime) {
+ await sleep(waitTime);
+ }
if (state === 'hover') {
await page.getByTestId(elementTestId).hover();
} else if (state === 'focus-visible') {
@@ -153,6 +158,9 @@ export function testEach(
,
);
+ if (waitTime) {
+ await sleep(waitTime);
+ }
if (state === 'hover') {
await page.getByTestId(elementTestId).hover();
} else if (state === 'focus-visible') {
diff --git a/apps/tester/tests/06-aspect-ratio.spec.tsx b/apps/tester/tests/06-aspect-ratio.spec.tsx
new file mode 100644
index 0000000..c9097da
--- /dev/null
+++ b/apps/tester/tests/06-aspect-ratio.spec.tsx
@@ -0,0 +1,88 @@
+import { AspectRatio as JoyAspectRatio } from '@mui/joy';
+import { AspectRatio as TJAspectRatio } from 'tailwind-joy/components';
+
+import type { Fixture } from '@/settings';
+import { testEach } from '@/settings';
+
+const containerClassName =
+ 'flex h-[200px] w-[400px] items-center justify-center p-2';
+
+const fixtures: Fixture[] = [
+ {
+ title: 'basics',
+ alterSizes: ['md'],
+ alterColors: ['primary'],
+ renderJoyElement({ testId, size, variant, color }) {
+ return (
+
+ Lorem ipsum
+
+ );
+ },
+ renderTjElement({ testId, size, variant, color }) {
+ return (
+
+ Lorem ipsum
+
+ );
+ },
+ },
+ {
+ title: 'ratio',
+ alterSizes: ['md'],
+ alterColors: ['primary'],
+ renderJoyElement({ testId, size, variant, color }) {
+ return (
+
+ Lorem ipsum
+
+ );
+ },
+ renderTjElement({ testId, size, variant, color }) {
+ return (
+
+ Lorem ipsum
+
+ );
+ },
+ },
+ {
+ title: 'objectFit',
+ alterSizes: ['md'],
+ alterColors: ['primary'],
+ waitTime: 3000,
+ renderJoyElement({ testId, size, variant, color }) {
+ return (
+
+
+
+ );
+ },
+ renderTjElement({ testId, size, variant, color }) {
+ return (
+
+
+
+ );
+ },
+ },
+];
+
+testEach(fixtures, {
+ containerClassName,
+ viewport: { width: 500, height: 500 },
+});
diff --git a/apps/website/docs/apis/06-aspect-ratio.md b/apps/website/docs/apis/06-aspect-ratio.md
new file mode 100644
index 0000000..7dc99a5
--- /dev/null
+++ b/apps/website/docs/apis/06-aspect-ratio.md
@@ -0,0 +1,110 @@
+---
+sidebar_position: 6
+title:
+---
+
+# AspectRatio API
+
+
+
+API reference docs for the React AspectRatio component.
+Learn about the props of this exported module.
+
+## Demos
+
+:::tip
+
+For examples and details on the usage of this React component, visit the component demo pages:
+
+- [AspectRatio](../components/aspect-ratio)
+
+:::
+
+## Import
+
+```tsx
+import { AspectRatio } from 'tailwind-joy/components';
+```
+
+## Props
+
+:::info
+
+The `ref` is forwarded to the root element.
+
+:::
+
+### `className`
+
+Class name applied to the root element.
+
+- Type: `string`
+
+### `color`
+
+The color of the component.
+
+- Type: `'primary' | 'neutral' | 'danger' | 'success' | 'warning'`
+- Default: `'neutral'`
+
+### `component`
+
+The component used for the root node.
+
+- Type: `keyof JSX.IntrinsicElements`
+- Default: `'div'`
+
+### `flex`
+
+By default, the AspectRatio will maintain the aspect ratio of its content.
+Set this prop to `true` when the container is a flex row and you want the AspectRatio to fill the height of its container.
+
+- Type: `boolean`
+- Default: `false`
+
+### `maxHeight`
+
+The maximum calculated height of the element (not the CSS height).
+
+- Type: `number | string`
+
+### `minHeight`
+
+The minimum calculated height of the element (not the CSS height).
+
+- Type: `number | string`
+
+### `objectFit`
+
+The CSS object-fit value of the first-child.
+
+- Type: `'-moz-initial' | 'contain' | 'cover' | 'fill' | 'inherit' | 'initial' | 'none' | 'revert-layer' | 'revert' | 'scale-down' | 'unset'`
+- Default: `'cover'`
+
+### `ratio`
+
+The aspect-ratio of the element.
+The current implementation uses padding instead of the CSS aspect-ratio.
+
+- Type: `number | string`
+- Default: `'16/9'`
+
+### `slotProps`
+
+The props used for each slot inside.
+
+- Type:
+ ```tsx
+ {
+ root?: ComponentProps<'div'>;
+ content?: ComponentProps<'div'>;
+ }
+ ```
+- Default: `{}`
+
+### `variant`
+
+The variant of the component.
+
+- Type: `'solid' | 'soft' | 'outlined' | 'plain'`
+- Default: `'soft'`
diff --git a/apps/website/docs/components/02-data-display/01-aspect-ratio.mdx b/apps/website/docs/components/02-data-display/01-aspect-ratio.mdx
new file mode 100644
index 0000000..e641a29
--- /dev/null
+++ b/apps/website/docs/components/02-data-display/01-aspect-ratio.mdx
@@ -0,0 +1,226 @@
+---
+sidebar_position: 1
+slug: /components/aspect-ratio
+---
+
+import {
+ AspectRatioBasics,
+ AspectRatioVariants,
+ AspectRatioRatio,
+ AspectRatioObjectFit,
+ AspectRatioMediaPlaceholder,
+ AspectRatioMinimumAndMaximumHeight,
+} from '@site/src/demos/aspect-ratio';
+
+# AspectRatio
+
+
+
+The Aspect Ratio component resizes its contents to match the desired ratio.
+
+## Basics
+
+```tsx
+import { AspectRatio } from 'tailwind-joy/components';
+```
+
+The Aspect Ratio component wraps around the content that it resizes.
+The element to be resized must be the first direct child.
+The default ratio is `16/9`.
+
+
+
+```tsx
+import { AspectRatio, Typography } from 'tailwind-joy/components';
+
+export function AspectRatioBasics() {
+ return (
+
+
+ 16/9
+
+
+ );
+}
+```
+
+## Customization
+
+### Variants
+
+The Aspect Ratio component supports four variants: `solid`, `soft` (default), `outlined`, and `plain`.
+
+
+
+```tsx
+import { AspectRatio, Box, Typography } from 'tailwind-joy/components';
+
+export function AspectRatioVariants() {
+ return (
+
+
+
+
+ Solid
+
+
+
+
+
+
+ Soft
+
+
+
+
+
+
+ Outlined
+
+
+
+
+
+
+ Plain
+
+
+
+
+ );
+}
+```
+
+### Ratio
+
+Use the `ratio` prop to change the aspect ratio, following the pattern `width/height`.
+For example, the demo below uses a ratio of `4/3`, which is a common alternative to the default `16/9`:
+
+
+
+```tsx
+import { AspectRatio, Typography } from 'tailwind-joy/components';
+
+export function AspectRatioRatio() {
+ return (
+
+
+ 4/3
+
+
+ );
+}
+```
+
+### Object fit
+
+When the content inside the Aspect Ratio component is an image or a video, you can use the `objectFit` prop to control how it's resized.
+
+This prop gives you access to all of the values associated with the CSS `object-fit` property: `cover` (default), `contain`, `fill`, `scaleDown`, `initial`, `inherit`, and `none`.
+
+
+
+```tsx
+import { AspectRatio, Box } from 'tailwind-joy/components';
+
+export function AspectRatioObjectFit() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+### Media placeholder
+
+Use a `
`, or a [Box](./box) component paired with an icon, as a fallback when there is no media content provided:
+
+
+
+```tsx
+import { MdImage } from 'react-icons/md';
+import { AspectRatio, Sheet, Typography } from 'tailwind-joy/components';
+import { iconClass } from 'tailwind-joy/utils';
+import { twMerge } from 'tailwind-merge';
+
+export function AspectRatioMediaPlaceholder() {
+ return (
+
+
+
+
+
+
+
+ Title
+ Description of the card.
+
+
+ );
+}
+```
+
+### Minimum and maximum height
+
+Use the `minHeight` and `maxHeight` props to set the lower and upper bound for the height of the content.
+This is useful when the Aspect Ratio component wraps dynamic-width content, as shown in the demo below:
+
+
+
+```tsx
+import { AspectRatio, Box } from 'tailwind-joy/components';
+
+export function AspectRatioMinimumAndMaximumHeight() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+## Anatomy
+
+The Aspect Ratio component is composed of a root `
` with a content `
` nested inside; the child component is given a `data-first-child` attribute for styling purposes:
+
+```html
+
+```
+
+## API
+
+- [`
`](../apis/aspect-ratio)
+- [`
`](../apis/box)
+- [`
`](../apis/sheet)
+- [`
`](../apis/typography)
diff --git a/apps/website/src/demos/aspect-ratio/Basics.tsx b/apps/website/src/demos/aspect-ratio/Basics.tsx
new file mode 100644
index 0000000..cd9d45c
--- /dev/null
+++ b/apps/website/src/demos/aspect-ratio/Basics.tsx
@@ -0,0 +1,14 @@
+import { AspectRatio, Typography } from 'tailwind-joy/components';
+import { DisplayStand } from '@site/src/components/docs/DisplayStand';
+
+export function AspectRatioBasics() {
+ return (
+
+
+
+ 16/9
+
+
+
+ );
+}
diff --git a/apps/website/src/demos/aspect-ratio/MediaPlaceholder.tsx b/apps/website/src/demos/aspect-ratio/MediaPlaceholder.tsx
new file mode 100644
index 0000000..e0ccdf1
--- /dev/null
+++ b/apps/website/src/demos/aspect-ratio/MediaPlaceholder.tsx
@@ -0,0 +1,32 @@
+import { MdImage } from 'react-icons/md';
+import { AspectRatio, Sheet, Typography } from 'tailwind-joy/components';
+import { iconClass } from 'tailwind-joy/utils';
+import { twMerge } from 'tailwind-merge';
+import { DisplayStand } from '@site/src/components/docs/DisplayStand';
+
+export function AspectRatioMediaPlaceholder() {
+ return (
+
+ {/* TODO: Replace Sheet with Card. */}
+
+
+
+
+
+
+
+ Title
+ Description of the card.
+
+
+
+ );
+}
diff --git a/apps/website/src/demos/aspect-ratio/MinimumAndMaximumHeight.tsx b/apps/website/src/demos/aspect-ratio/MinimumAndMaximumHeight.tsx
new file mode 100644
index 0000000..229eea1
--- /dev/null
+++ b/apps/website/src/demos/aspect-ratio/MinimumAndMaximumHeight.tsx
@@ -0,0 +1,18 @@
+import { AspectRatio, Box } from 'tailwind-joy/components';
+import { DisplayStand } from '@site/src/components/docs/DisplayStand';
+
+export function AspectRatioMinimumAndMaximumHeight() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/website/src/demos/aspect-ratio/ObjectFit.tsx b/apps/website/src/demos/aspect-ratio/ObjectFit.tsx
new file mode 100644
index 0000000..96ffd5f
--- /dev/null
+++ b/apps/website/src/demos/aspect-ratio/ObjectFit.tsx
@@ -0,0 +1,18 @@
+import { AspectRatio, Box } from 'tailwind-joy/components';
+import { DisplayStand } from '@site/src/components/docs/DisplayStand';
+
+export function AspectRatioObjectFit() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/website/src/demos/aspect-ratio/Ratio.tsx b/apps/website/src/demos/aspect-ratio/Ratio.tsx
new file mode 100644
index 0000000..d5445e8
--- /dev/null
+++ b/apps/website/src/demos/aspect-ratio/Ratio.tsx
@@ -0,0 +1,18 @@
+import { AspectRatio, Typography } from 'tailwind-joy/components';
+import { DisplayStand } from '@site/src/components/docs/DisplayStand';
+
+export function AspectRatioRatio() {
+ return (
+
+
+
+ 4/3
+
+
+
+ );
+}
diff --git a/apps/website/src/demos/aspect-ratio/Variants.tsx b/apps/website/src/demos/aspect-ratio/Variants.tsx
new file mode 100644
index 0000000..401e973
--- /dev/null
+++ b/apps/website/src/demos/aspect-ratio/Variants.tsx
@@ -0,0 +1,44 @@
+import { AspectRatio, Box, Typography } from 'tailwind-joy/components';
+import { DisplayStand } from '@site/src/components/docs/DisplayStand';
+
+export function AspectRatioVariants() {
+ return (
+
+ {/* TODO: Replace Box with Grid. */}
+
+ {/* TODO: Replace Box with Grid. */}
+
+
+
+ Solid
+
+
+
+ {/* TODO: Replace Box with Grid. */}
+
+
+
+ Soft
+
+
+
+ {/* TODO: Replace Box with Grid. */}
+
+
+
+ Outlined
+
+
+
+ {/* TODO: Replace Box with Grid. */}
+
+
+
+ Plain
+
+
+
+
+
+ );
+}
diff --git a/apps/website/src/demos/aspect-ratio/index.ts b/apps/website/src/demos/aspect-ratio/index.ts
new file mode 100644
index 0000000..6154dc2
--- /dev/null
+++ b/apps/website/src/demos/aspect-ratio/index.ts
@@ -0,0 +1,6 @@
+export { AspectRatioBasics } from './Basics';
+export { AspectRatioVariants } from './Variants';
+export { AspectRatioRatio } from './Ratio';
+export { AspectRatioObjectFit } from './ObjectFit';
+export { AspectRatioMediaPlaceholder } from './MediaPlaceholder';
+export { AspectRatioMinimumAndMaximumHeight } from './MinimumAndMaximumHeight';
diff --git a/packages/tailwind-joy/README.md b/packages/tailwind-joy/README.md
index b05709c..32f7eaf 100644
--- a/packages/tailwind-joy/README.md
+++ b/packages/tailwind-joy/README.md
@@ -21,6 +21,7 @@ https://tailwind-joy.vercel.app
### Data display
+- Aspect Ratio
- Divider
- Typography
diff --git a/packages/tailwind-joy/src/components.ts b/packages/tailwind-joy/src/components.ts
index 2417b6b..9d182ed 100644
--- a/packages/tailwind-joy/src/components.ts
+++ b/packages/tailwind-joy/src/components.ts
@@ -1,3 +1,4 @@
+export { AspectRatio } from './components/AspectRatio';
export { Box } from './components/Box';
export { Button } from './components/Button';
export { ButtonGroup } from './components/ButtonGroup';
diff --git a/packages/tailwind-joy/src/components/AspectRatio.tsx b/packages/tailwind-joy/src/components/AspectRatio.tsx
new file mode 100644
index 0000000..158c55e
--- /dev/null
+++ b/packages/tailwind-joy/src/components/AspectRatio.tsx
@@ -0,0 +1,240 @@
+import { clsx } from 'clsx';
+import type { ComponentProps, ForwardedRef } from 'react';
+import {
+ forwardRef,
+ createElement,
+ cloneElement,
+ isValidElement,
+ Children,
+ useMemo,
+} from 'react';
+import type {
+ BaseVariants,
+ GeneratorInput,
+ GenericComponentPropsWithVariants,
+} from '@/base/types';
+import { twMerge } from '../base/alias';
+import { addPrefix, toVariableClass } from '../base/modifier';
+import { theme } from '../base/theme';
+import { baseTokens } from '../base/tokens';
+import { excludeClassName } from '../base/utils';
+
+type AspectRatioObjectFit =
+ | '-moz-initial'
+ | 'contain'
+ | 'cover'
+ | 'fill'
+ | 'inherit'
+ | 'initial'
+ | 'none'
+ | 'revert-layer'
+ | 'revert'
+ | 'scale-down'
+ | 'unset';
+
+function aspectRatioRootVariants(
+ props?: Pick
& {
+ flex?: boolean;
+ },
+) {
+ const { color = 'neutral', variant = 'soft', flex = false } = props ?? {};
+
+ return twMerge(
+ clsx([
+ 'tj-aspect-ratio-root group/tj-aspect-ratio',
+ '[--AspectRatio-paddingBottom:clamp(var(--AspectRatio-minHeight),calc(100%/(var(--tj-AspectRatio-ratio))),var(--AspectRatio-maxHeight))]',
+ '[--AspectRatio-maxHeight:var(--tj-AspectRatio-maxHeight)]',
+ '[--AspectRatio-minHeight:var(--tj-AspectRatio-minHeight)]',
+ color !== 'neutral' || variant === 'solid'
+ ? '[--Icon-color:currentColor] dark:[--Icon-color:currentColor]'
+ : toVariableClass(baseTokens.text.icon, 'Icon-color'),
+ 'rounded-[var(--AspectRatio-radius)]',
+ flex ? 'flex' : 'block',
+ flex ? 'flex-1' : 'flex-[initial]',
+ 'flex-col',
+ 'm-[var(--AspectRatio-margin)]',
+ ]),
+ );
+}
+
+function aspectRatioContentVariants(
+ props?: Pick,
+) {
+ const { color = 'neutral', variant = 'soft' } = props ?? {};
+
+ return twMerge(
+ clsx([
+ 'tj-aspect-ratio-content',
+ 'flex-1',
+ 'relative',
+ 'rounded-[inherit]',
+ 'h-0',
+ 'pb-[calc(var(--AspectRatio-paddingBottom)-2*var(--variant-borderWidth,0px))]',
+ 'overflow-hidden',
+ '[transition:inherit]',
+ addPrefix(
+ clsx([
+ 'flex',
+ 'justify-center',
+ 'items-center',
+ 'box-border',
+ 'absolute',
+ 'w-full',
+ 'h-full',
+ '[object-fit:var(--tj-AspectRatio-objectFit)]',
+ 'm-0',
+ 'p-0',
+ addPrefix(
+ clsx([
+ 'w-full',
+ 'h-full',
+ '[object-fit:var(--tj-AspectRatio-objectFit)]',
+ ]),
+ '[&>img]:',
+ ),
+ ]),
+ '[&_[data-first-child]]:',
+ ),
+ theme.typography['body-md'].className,
+ theme.variants[variant][color].className,
+ ]),
+ );
+}
+
+type AspectRatioRootVariants = Pick & {
+ flex?: boolean;
+ maxHeight?: number | string;
+ minHeight?: number | string;
+ objectFit?: AspectRatioObjectFit;
+ ratio?: number | string;
+} & {
+ slotProps?: {
+ root?: ComponentProps<'div'>;
+ content?: ComponentProps<'div'>;
+ };
+};
+
+type AspectRatioRootProps = GenericComponentPropsWithVariants<
+ 'div',
+ AspectRatioRootVariants,
+ T
+>;
+
+function AspectRatioRoot<
+ T extends keyof JSX.IntrinsicElements | undefined = undefined,
+>(
+ {
+ // ---- non-passing props ----
+ // base variants
+ color = 'neutral',
+ variant = 'soft',
+
+ // non-base variants
+ className,
+ flex = false,
+ maxHeight,
+ minHeight,
+ objectFit = 'cover',
+ ratio = '16/9',
+ style,
+
+ // slot props
+ slotProps = {},
+
+ // others
+ component = 'div',
+ children,
+ ...otherProps
+ // ---------------------------
+ }: AspectRatioRootProps,
+ ref: ForwardedRef,
+) {
+ const slotPropsWithoutClassName = useMemo(
+ () => excludeClassName(slotProps),
+ [slotProps],
+ );
+
+ return createElement(
+ component,
+ {
+ ref,
+ className: twMerge(
+ aspectRatioRootVariants({
+ color,
+ variant,
+ flex,
+ }),
+ className,
+ slotProps.root?.className ?? '',
+ ),
+ style: {
+ ...style,
+ ...(maxHeight === undefined
+ ? {
+ '--tj-AspectRatio-maxHeight': '9999px',
+ }
+ : {
+ '--tj-AspectRatio-maxHeight':
+ typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight,
+ }),
+ ...(minHeight === undefined
+ ? {
+ '--tj-AspectRatio-minHeight': '0px',
+ }
+ : {
+ '--tj-AspectRatio-minHeight':
+ typeof minHeight === 'number' ? `${minHeight}px` : minHeight,
+ }),
+ '--tj-AspectRatio-objectFit': objectFit,
+ '--tj-AspectRatio-ratio': ratio,
+ },
+ ...otherProps,
+ ...(slotPropsWithoutClassName.root ?? {}),
+ },
+
+ {Children.map(children, (child, index) => {
+ if (!isValidElement(child)) {
+ return child;
+ }
+
+ return cloneElement(child, {
+ // @ts-expect-error
+ 'data-first-child': index === 0 ? '' : undefined,
+ });
+ })}
+
,
+ );
+}
+
+export const AspectRatio = forwardRef(AspectRatioRoot) as <
+ T extends keyof JSX.IntrinsicElements | undefined = undefined,
+>(
+ props: AspectRatioRootProps & { ref?: ForwardedRef },
+) => JSX.Element;
+
+export const generatorInputs: GeneratorInput[] = [
+ {
+ generatorFn: aspectRatioRootVariants,
+ variants: {
+ color: ['primary', 'neutral', 'danger', 'success', 'warning'],
+ variant: ['solid', 'soft', 'outlined', 'plain'],
+ flex: [false, true],
+ },
+ },
+ {
+ generatorFn: aspectRatioContentVariants,
+ variants: {
+ color: ['primary', 'neutral', 'danger', 'success', 'warning'],
+ variant: ['solid', 'soft', 'outlined', 'plain'],
+ },
+ },
+];
diff --git a/packages/tailwind-joy/src/plugins/safelist-generator.ts b/packages/tailwind-joy/src/plugins/safelist-generator.ts
index 73159a1..74b9a05 100644
--- a/packages/tailwind-joy/src/plugins/safelist-generator.ts
+++ b/packages/tailwind-joy/src/plugins/safelist-generator.ts
@@ -1,4 +1,5 @@
import type { GeneratorInput } from '../base/types';
+import { generatorInputs as aspectRatioClassNameGeneratorInputs } from '../components/AspectRatio';
import { generatorInputs as boxClassNameGeneratorInputs } from '../components/Box';
import { generatorInputs as buttonClassNameGeneratorInputs } from '../components/Button';
import { generatorInputs as buttonGroupClassNameGeneratorInputs } from '../components/ButtonGroup';
@@ -20,6 +21,7 @@ import { generatorInputs as adaptedIconClassNameGeneratorInputs } from '../compo
const SPACE = ' ';
const inputs: GeneratorInput[] = [
+ ...aspectRatioClassNameGeneratorInputs,
...boxClassNameGeneratorInputs,
...buttonClassNameGeneratorInputs,
...buttonGroupClassNameGeneratorInputs,