Skip to content

Media Gallery

One to ten images in a mosaic, laid out by how many there are.

One to ten images in a mosaic, laid out by how many there are. Compose it from the exported registry parts so local content and behaviour stay in the application while the visual contract stays token-driven.

import {
  MediaGallery,
  MediaGalleryItem,
} from "@/registry/bases/newt/ui/media-gallery"

/* Drawn rather than fetched, so the demo holds up offline. */
function tile(from: string, to: string): string {
  return (
    "data:image/svg+xml;utf8," +
    encodeURIComponent(
      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
        <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/>
        </linearGradient></defs>
        <rect width="200" height="200" fill="url(#g)"/>
      </svg>`
    )
  )
}

export default function MediaGalleryDemo() {
  return (
    <MediaGallery className="h-[220px]">
      <MediaGalleryItem
        src={tile("#5865f2", "#1e1f22")}
        alt="The yard at noon"
      />
      <MediaGalleryItem src={tile("#23a55a", "#1e1f22")} alt="The roof line" />
      <MediaGalleryItem src={tile("#eb459e", "#1e1f22")} alt="The skyline" />
    </MediaGallery>
  )
}
<script setup lang="ts">
import {
  MediaGallery,
  MediaGalleryItem,
} from "~~/registry/bases/newt/ui/media-gallery"

/* Drawn rather than fetched, so the demo holds up offline. */
function tile(from: string, to: string): string {
  return (
    "data:image/svg+xml;utf8," +
    encodeURIComponent(
      `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
        <defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/>
        </linearGradient></defs>
        <rect width="200" height="200" fill="url(#g)"/>
      </svg>`
    )
  )
}
</script>

<template>
  <MediaGallery class="h-[220px]" :count="3">
    <MediaGalleryItem
      :src="tile('#5865f2', '#1e1f22')"
      alt="The yard at noon"
    />
    <MediaGalleryItem :src="tile('#23a55a', '#1e1f22')" alt="The roof line" />
    <MediaGalleryItem :src="tile('#eb459e', '#1e1f22')" alt="The skyline" />
  </MediaGallery>
</template>

Installation

pnpm dlx newtui@latest add media-gallery
npx newtui@latest add media-gallery
yarn dlx newtui@latest add media-gallery
bunx newtui@latest add media-gallery

Copy the source into your project

import * as React from "react"

import { cn } from "@/lib/utils"

/*
 * One to ten images in a mosaic. The layout is decided by how many there are,
 * not by what each one is: two side by side, three as one large and two
 * stacked, more than that in a grid. The count drives it because that is the
 * only thing the gallery knows before the images load.
 */
function layoutFor(count: number): string {
  if (count <= 1) return "grid-cols-1"
  if (count === 2) return "grid-cols-2"
  /* Three: the first takes the full height beside a stacked pair. */
  if (count === 3) return "grid-cols-2 grid-rows-2 [&>*:first-child]:row-span-2"
  if (count === 4) return "grid-cols-2"
  return "grid-cols-3"
}

export interface MediaGalleryProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * How many items the grid holds. Counted from the children when it is not
   * given, which is what a static list wants.
   */
  count?: number
}

const MediaGallery = React.forwardRef<HTMLDivElement, MediaGalleryProps>(
  ({ className, count, children, ...props }, ref) => (
    <div
      ref={ref}
      className={cn(
        "grid max-w-[550px] auto-rows-[1fr] gap-1 overflow-hidden rounded-lg",
        layoutFor(count ?? React.Children.count(children)),
        className
      )}
      {...props}
    >
      {children}
    </div>
  )
)
MediaGallery.displayName = "MediaGallery"

export interface MediaGalleryItemProps extends React.ImgHTMLAttributes<HTMLImageElement> {
  /** Blurs the image until it is clicked. */
  spoiler?: boolean
}

const MediaGalleryItem = React.forwardRef<
  HTMLImageElement,
  MediaGalleryItemProps
>(({ className, spoiler = false, alt = "", ...props }, ref) => {
  const [revealed, setRevealed] = React.useState(false)
  const hidden = spoiler && !revealed

  return (
    <img
      ref={ref}
      alt={alt}
      data-spoiler={hidden || undefined}
      role={hidden ? "button" : undefined}
      tabIndex={hidden ? 0 : undefined}
      aria-label={hidden ? "Spoiler, click to reveal" : undefined}
      onClick={() => setRevealed(true)}
      onKeyDown={(event) => {
        if (!hidden) return
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault()
          setRevealed(true)
        }
      }}
      /*
       * `object-cover` on a stretched cell: the mosaic keeps its shape whatever
       * aspect ratio arrives, which is the point of a mosaic.
       */
      className={cn(
        "h-full w-full object-cover",
        hidden && "cursor-pointer blur-lg",
        className
      )}
      {...props}
    />
  )
})
MediaGalleryItem.displayName = "MediaGalleryItem"

export { MediaGallery, MediaGalleryItem }
<script setup lang="ts">
import { computed, useSlots, type HTMLAttributes, type VNode } from "vue"

import { cn } from "@/lib/utils"

import { mediaGalleryLayout } from "."

const props = defineProps<{
  class?: HTMLAttributes["class"]
  /**
   * How many items the grid holds. Counted from the slot when it is not
   * given, which is what a static list wants.
   */
  count?: number
}>()

const slots = useSlots()

const items = computed(() => {
  if (props.count !== undefined) return props.count
  const nodes: VNode[] = slots.default?.() ?? []
  /* A `v-for` arrives as one fragment, so its children are the real count. */
  return nodes.reduce(
    (total, node) =>
      total + (Array.isArray(node.children) ? node.children.length : 1),
    0
  )
})
</script>

<template>
  <div
    :class="
      cn(
        'grid max-w-[550px] auto-rows-[1fr] gap-1 overflow-hidden rounded-lg',
        mediaGalleryLayout(items),
        props.class
      )
    "
  >
    <slot />
  </div>
</template>
<script setup lang="ts">
import { ref, type HTMLAttributes } from "vue"

import { cn } from "@/lib/utils"

const props = withDefaults(
  defineProps<{
    class?: HTMLAttributes["class"]
    src: string
    alt?: string
    /** Blurs the image until it is clicked. */
    spoiler?: boolean
  }>(),
  { alt: "", spoiler: false }
)

const revealed = ref(false)

function reveal() {
  revealed.value = true
}

function onKeydown(event: KeyboardEvent) {
  if (revealed.value || !props.spoiler) return
  if (event.key === "Enter" || event.key === " ") {
    event.preventDefault()
    reveal()
  }
}
</script>

<template>
  <img
    :src="props.src"
    :alt="props.alt"
    :data-spoiler="props.spoiler && !revealed ? true : undefined"
    :role="props.spoiler && !revealed ? 'button' : undefined"
    :tabindex="props.spoiler && !revealed ? 0 : undefined"
    :aria-label="
      props.spoiler && !revealed ? 'Spoiler, click to reveal' : undefined
    "
    :class="
      cn(
        'h-full w-full object-cover',
        props.spoiler && !revealed && 'cursor-pointer blur-lg',
        props.class
      )
    "
    @click="reveal"
    @keydown="onKeydown"
  />
</template>
export { default as MediaGallery } from "./MediaGallery.vue"
export { default as MediaGalleryItem } from "./MediaGalleryItem.vue"

/*
 * One to ten images in a mosaic. The layout is decided by how many there are,
 * not by what each one is: two side by side, three as one large and two
 * stacked, more than that in a grid. The count drives it because that is the
 * only thing the gallery knows before the images load.
 */
export function mediaGalleryLayout(count: number): string {
  if (count <= 1) return "grid-cols-1"
  if (count === 2) return "grid-cols-2"
  /* Three: the first takes the full height beside a stacked pair. */
  if (count === 3) return "grid-cols-2 grid-rows-2 [&>*:first-child]:row-span-2"
  if (count === 4) return "grid-cols-2"
  return "grid-cols-3"
}

Update the import paths

The files import @/lib/utils and sibling components through the aliases in your components.json. Point them at wherever those live in your project.

Usage

import { MediaGallery } from "@/components/ui/media-gallery"

export function Example() {
  return <MediaGallery />
}
<script setup lang="ts">
import { MediaGallery, MediaGalleryItem } from "@/components/ui/media-gallery"
</script>

<template>
  <MediaGallery />
</template>

API reference

MediaGallery

PropType
children / default slot?React.ReactNode / Vue slot

Content rendered inside the component.

TypeReact.ReactNode / Vue slot
className / class?string

Additional classes merged with the component defaults.

Typestring

Accessibility

Preserve the native semantics of the rendered elements and pass through ARIA attributes when the surrounding context needs a label or description. Any interactive child needs a visible focus indicator, and meaning must not rely on colour alone.

Tokens

Media Gallery reads its colours, radii and motion from the --newt-* tokens installed with the library. Override a token and every component that reads it follows, including this one — see the token layer.