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-gallerynpx newtui@latest add media-galleryyarn dlx newtui@latest add media-gallerybunx newtui@latest add media-galleryCopy 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
children / default slot?React.ReactNode / Vue slot
Content rendered inside the component.
React.ReactNode / Vue slotclassName / class?string
Additional classes merged with the component defaults.
stringAccessibility
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.