Skip to content

Media-frame hero

A 16:9 media slot whose aspect ratio is reserved before anything loads.

Marketingstarterherovideomediaaspect-ratio

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

import { Play } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'

/**
 * Media-frame hero
 *
 * A hero built around a 16:9 media slot. The frame reserves its aspect ratio
 * before anything loads, so the page never shifts — the layout-stability
 * problem that image-led heroes usually cause.
 *
 * Foundry ships no media, so the frame renders a labelled placeholder. Drop a
 * `<video>` or `<Image>` in and nothing else changes.
 */
export default function MediaFrameHero() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl">
            Two minutes is enough to see the idea.
          </h2>
          <p className="mx-auto mt-4 max-w-lg text-md text-ink-muted">
            Watch a landing page assembled from nine sections, then re-themed twice without touching
            a component.
          </p>
        </div>

        <div className="mx-auto mt-10 max-w-4xl">
          <div className="relative aspect-video overflow-hidden rounded-xl border border-line bg-surface-sunken shadow-lg">
            <div className="grid-paper absolute inset-0 opacity-70" aria-hidden="true" />
            <div className="relative flex h-full flex-col items-center justify-center gap-4 text-center">
              <span className="flex size-14 items-center justify-center rounded-full border border-line bg-surface text-accent shadow-md">
                <Play className="ml-0.5 size-6 fill-current" aria-hidden="true" />
              </span>
              <div>
                <p className="text-sm font-medium text-ink">Media slot — 16:9</p>
                <p className="mt-1 text-xs text-ink-muted">
                  The aspect ratio is reserved before load, so nothing shifts.
                </p>
              </div>
            </div>
          </div>

          <div className="mt-6 flex flex-wrap items-center justify-center gap-3">
            <ButtonLink href="/patterns/saas-landing">Open the assembled page</ButtonLink>
            <ButtonLink href="/playground" variant="outline">
              Re-theme it yourself
            </ButtonLink>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/hero/media-frame.tsx

import { Play } from 'lucide-react'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'

/**
 * Media-frame hero
 *
 * A hero built around a 16:9 media slot. The frame reserves its aspect ratio
 * before anything loads, so the page never shifts — the layout-stability
 * problem that image-led heroes usually cause.
 *
 * Foundry ships no media, so the frame renders a labelled placeholder. Drop a
 * `<video>` or `<Image>` in and nothing else changes.
 */
export default function MediaFrameHero() {
  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container>
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="display-type text-3xl leading-tight font-semibold text-ink-strong sm:text-4xl">
            Two minutes is enough to see the idea.
          </h2>
          <p className="mx-auto mt-4 max-w-lg text-md text-ink-muted">
            Watch a landing page assembled from nine sections, then re-themed twice without touching
            a component.
          </p>
        </div>

        <div className="mx-auto mt-10 max-w-4xl">
          <div className="relative aspect-video overflow-hidden rounded-xl border border-line bg-surface-sunken shadow-lg">
            <div className="grid-paper absolute inset-0 opacity-70" aria-hidden="true" />
            <div className="relative flex h-full flex-col items-center justify-center gap-4 text-center">
              <span className="flex size-14 items-center justify-center rounded-full border border-line bg-surface text-accent shadow-md">
                <Play className="ml-0.5 size-6 fill-current" aria-hidden="true" />
              </span>
              <div>
                <p className="text-sm font-medium text-ink">Media slot — 16:9</p>
                <p className="mt-1 text-xs text-ink-muted">
                  The aspect ratio is reserved before load, so nothing shifts.
                </p>
              </div>
            </div>
          </div>

          <div className="mt-6 flex flex-wrap items-center justify-center gap-3">
            <ButtonLink href="/patterns/saas-landing">Open the assembled page</ButtonLink>
            <ButtonLink href="/playground" variant="outline">
              Re-theme it yourself
            </ButtonLink>
          </div>
        </div>
      </Container>
    </section>
  )
}

components/ui/button.tsx

import type { ButtonHTMLAttributes, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { variants } from '@/lib/variants'
import { Spinner } from './spinner'

/**
 * Button
 *
 * The whole action surface of Foundry in one component. It is intentionally a
 * *shared* component (no `'use client'`): rendered from a Server Component it
 * ships zero JavaScript, and it upgrades to a client island automatically when
 * a Client Component imports it.
 *
 * Height, padding and radius resolve from density and radius tokens, so a
 * button restyles itself when the density or palette axis changes. Focus is
 * handled once, globally, by the `:focus-visible` rule in `globals.css`.
 */
const buttonVariants = variants(
  cn(
    'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium',
    'transition-colors duration-150 ease-standard select-none',
    'disabled:pointer-events-none disabled:opacity-50',
    'aria-disabled:pointer-events-none aria-disabled:opacity-50',
  ),
  {
    variants: {
      variant: {
        primary: 'bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-active',
        secondary: 'bg-surface-inverse text-ink-inverse hover:opacity-90 active:opacity-80',
        outline: 'border border-line-strong bg-surface text-ink hover:bg-surface-sunken',
        ghost: 'text-ink hover:bg-surface-sunken active:bg-surface-sunken',
        destructive: 'bg-danger text-white hover:opacity-90 active:opacity-80',
        success: 'bg-success text-white hover:opacity-90 active:opacity-80',
        warning: 'bg-warning text-white hover:opacity-90 active:opacity-80',
        link: 'text-accent underline underline-offset-4 hover:text-accent-hover',
        soft: 'bg-accent-soft text-accent-soft-ink border border-accent-line hover:brightness-[0.97]',
      },
      size: {
        sm: 'h-control-sm px-3 text-xs rounded-md',
        md: 'h-control px-[var(--density-control-padding-x)] text-sm rounded-md',
        lg: 'h-control-lg px-5 text-base rounded-md',
        icon: 'h-control w-control p-0 rounded-md',
        'icon-sm': 'h-control-sm w-control-sm p-0 rounded-sm',
      },
      block: { true: 'w-full', false: '' },
    },
    defaultVariants: { variant: 'primary', size: 'md', block: false },
    compound: [
      { variant: 'link', size: 'sm', class: 'h-auto px-0' },
      { variant: 'link', size: 'md', class: 'h-auto px-0' },
      { variant: 'link', size: 'lg', class: 'h-auto px-0' },
    ],
  },
)

export type ButtonVariant =
  | 'primary'
  | 'secondary'
  | 'outline'
  | 'ghost'
  | 'destructive'
  | 'success'
  | 'warning'
  | 'link'
  | 'soft'

export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  /** Swaps content for a spinner while preserving the button's measured width. */
  loading?: boolean
  /** Announced by assistive tech while `loading` is true. */
  loadingLabel?: string
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
}

export function Button({
  variant = 'primary',
  size = 'md',
  block = false,
  loading = false,
  loadingLabel = 'Working',
  leadingIcon,
  trailingIcon,
  className,
  children,
  disabled,
  type = 'button',
  ...props
}: ButtonProps) {
  return (
    <button
      type={type}
      className={buttonVariants({ variant, size, block, className })}
      disabled={disabled ?? loading}
      aria-busy={loading || undefined}
      {...props}
    >
      {loading ? (
        <>
          {/* Label stays in the DOM but hidden so the control never collapses
              to spinner width halfway through an interaction. */}
          <span className="invisible flex items-center gap-2" aria-hidden="true">
            {leadingIcon}
            {children}
            {trailingIcon}
          </span>
          <span className="absolute inset-0 flex items-center justify-center">
            <Spinner size="sm" />
            <span className="sr-only">{loadingLabel}</span>
          </span>
        </>
      ) : (
        <>
          {leadingIcon}
          {children}
          {trailingIcon}
        </>
      )}
    </button>
  )
}

export interface ButtonLinkProps {
  href: string
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  className?: string
  children?: ReactNode
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
  'aria-label'?: string
  'aria-current'?: 'page' | 'step' | 'true' | undefined
  target?: string
  rel?: string
  prefetch?: boolean
}

/** Anchor styled as a button, for when the action is really navigation. */
export function ButtonLink({
  href,
  variant = 'primary',
  size = 'md',
  block = false,
  className,
  children,
  leadingIcon,
  trailingIcon,
  ...props
}: ButtonLinkProps) {
  return (
    <Link href={href} className={buttonVariants({ variant, size, block, className })} {...props}>
      {leadingIcon}
      {children}
      {trailingIcon}
    </Link>
  )
}

export { buttonVariants }

Demo source — adapt to your project. Foundry is not published as a package.

Usage

Reserving the ratio prevents the layout shift that image-led heroes normally cause. Foundry ships no media, so the frame renders a labelled placeholder you can replace directly.

  • Declare the aspect ratio in CSS, not by waiting for the asset to report it.
  • A play affordance must be a real button when the media is interactive.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Reserved 16:9 frame
  • Play affordance
  • Dual action

Accessibility

No layout shift
The frame occupies its final size from first paint.
Labelled placeholder
The placeholder explains itself rather than being an empty grey box.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.