Media and text
A headline, body copy and a picture side by side, stacked, or in three columns with the call to action on its own.
Install
If this is the first component in the project, set up once. This writes ermine.json and copies the theme stylesheet. The installation guide covers Vue, Nuxt and Astro.
npx ermineui initpnpm dlx ermineui inityarn dlx ermineui initbunx ermineui initThen add the block:
npx ermineui add media-textpnpm dlx ermineui add media-textyarn dlx ermineui add media-textbunx ermineui add media-textThat copies 3 files into src/components/blocks/media-text/, and the 1 component it is built from. Files you already have are kept.
Use
import { MediaText } from '@/components/blocks/media-text'<MediaText :title="title" :body="body" :image="{ src: '/studio.jpg', alt: 'The kiln, open' }" :primary-action="{ label: 'Shop', href: '/shop' }" />
<MediaText layout="columns" reverse ratio="1" note="Free delivery over $50.">
<p>Copy with <a href="/about">a link</a>, or more than one paragraph.</p>
<template #media><video src="/throwing.mp4" muted autoplay loop playsinline /></template>
</MediaText>In Nuxt, with the auto-import setting, the tags work in templates without the import. In Astro, use it inside a Vue island.
What gets copied
| File | Where it goes |
|---|---|
MediaText.vue | src/components/blocks/media-text/MediaText.vue |
index.ts | src/components/blocks/media-text/index.ts |
sample-data.ts | src/components/blocks/media-text/sample-data.ts |
It is built from: Button. These are added with it.
The folders are the suggested ones. ermine.json says where yours are.
Source
This is exactly what the command copies. To install by hand, create each file below and the helpers listed above.
MediaText.vue 248 lines
<script setup lang="ts">
import { useId } from 'vue'
import { Button } from '../../ui/button'
import { sampleMediaImage, sampleMediaText, type MediaTextAction, type MediaTextImage } from './sample-data'
withDefaults(
defineProps<{
/**
* How the parts are laid out on a wide screen. `split`: text beside the picture. `stacked`: the
* picture above, the text centred below. `columns`: text, picture, then the call to action in a
* column of its own. Every layout stacks on a narrow screen, picture first.
*/
layout?: 'split' | 'stacked' | 'columns'
/** Put the picture first: on the left in `split` and `columns`. */
reverse?: boolean
/** A short line above the title. */
eyebrow?: string
title?: string
/** The body copy. Use the default slot instead for more than one paragraph, or for links. */
body?: string
/** `null` for no picture, when the media slot is not used either. */
image?: MediaTextImage | null
/** The picture's shape, as a CSS aspect-ratio: "4 / 3", "1", "16 / 9". */
ratio?: string
/** A line beside the buttons: an offer, a price, a reassurance. An empty string leaves it out. */
note?: string
/** `null` leaves the button out. */
primaryAction?: MediaTextAction | null
secondaryAction?: MediaTextAction | null
/** Heading level of the title. The first section of a page may want h1. */
titleAs?: string
}>(),
{
layout: 'split',
eyebrow: sampleMediaText.eyebrow,
title: sampleMediaText.title,
body: sampleMediaText.body,
image: () => sampleMediaImage,
ratio: '4 / 3',
note: sampleMediaText.note,
primaryAction: () => sampleMediaText.primaryAction,
secondaryAction: () => sampleMediaText.secondaryAction,
titleAs: 'h2',
},
)
defineSlots<{
/** The body copy, in place of `body`. */
default?: () => unknown
/** Something other than a picture: a video, a chart, a product card. */
media?: () => unknown
/** Buttons or links of your own, in place of the two actions. */
actions?: () => unknown
}>()
const id = useId()
</script>
<template>
<section class="er-media-text" :data-layout="layout" :data-reverse="reverse || undefined" :aria-labelledby="`${id}-title`">
<div class="er-media-text-grid">
<div class="er-media-text-copy">
<p v-if="eyebrow" class="er-media-text-eyebrow">{{ eyebrow }}</p>
<component :is="titleAs" :id="`${id}-title`" class="er-media-text-title">{{ title }}</component>
<div class="er-media-text-body">
<slot>
<p v-if="body">{{ body }}</p>
</slot>
</div>
</div>
<div class="er-media-text-media" :style="{ '--er-media-text-ratio': ratio }">
<slot name="media">
<img v-if="image" :src="image.src" :alt="image.alt" loading="lazy" decoding="async" />
</slot>
</div>
<div v-if="note || primaryAction || secondaryAction || $slots.actions" class="er-media-text-cta">
<p v-if="note" class="er-media-text-note">{{ note }}</p>
<div class="er-media-text-actions">
<slot name="actions">
<Button v-if="primaryAction" as="a" :href="primaryAction.href">{{ primaryAction.label }}</Button>
<Button v-if="secondaryAction" as="a" :href="secondaryAction.href" variant="outline">{{ secondaryAction.label }}</Button>
</slot>
</div>
</div>
</div>
</section>
</template>
<style>
@layer components {
.er-media-text {
container-type: inline-size;
width: 100%;
}
/* Narrow: one column, picture first. The source order (copy, picture, call to action) is the reading order. */
.er-media-text-grid {
display: grid;
grid-template-areas: 'media' 'copy' 'cta';
gap: 1.25rem;
}
.er-media-text-copy {
grid-area: copy;
display: grid;
gap: 0.75rem;
align-content: start;
}
.er-media-text-eyebrow {
margin: 0;
color: var(--er-muted-foreground);
font-size: 0.8125rem;
font-weight: var(--er-weight-semibold);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.er-media-text-title {
margin: 0;
font-family: var(--er-font-display);
font-size: clamp(1.5rem, 5cqi, 2.25rem);
font-weight: var(--er-weight-heading);
letter-spacing: calc(-0.02em * var(--er-tracking-heading));
line-height: 1.15;
text-wrap: balance;
}
.er-media-text-body {
display: grid;
gap: 0.75rem;
color: var(--er-muted-foreground);
font-family: var(--er-font-prose);
font-size: 1rem;
line-height: 1.6;
text-wrap: pretty;
}
.er-media-text-body > * {
margin: 0;
}
.er-media-text-media {
grid-area: media;
overflow: hidden;
border-radius: var(--er-radius-xl);
background: var(--er-muted);
aspect-ratio: var(--er-media-text-ratio, 4 / 3);
}
.er-media-text-media > :is(img, video, picture, iframe) {
display: block;
width: 100%;
height: 100%;
border: 0;
object-fit: cover;
}
.er-media-text-cta {
grid-area: cta;
display: grid;
gap: 0.75rem;
align-content: start;
}
.er-media-text-note {
margin: 0;
font-size: 0.9375rem;
font-weight: var(--er-weight-medium);
}
.er-media-text-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Stacked: centred text under a wide picture. */
.er-media-text[data-layout='stacked'] :is(.er-media-text-copy, .er-media-text-cta) {
justify-items: center;
max-width: 40rem;
margin-inline: auto;
text-align: center;
}
.er-media-text[data-layout='stacked'] .er-media-text-actions {
justify-content: center;
}
@container (min-width: 40rem) {
.er-media-text-grid {
column-gap: clamp(2rem, 6cqi, 4rem);
}
/* Split: copy and call to action together, centred against the picture. */
.er-media-text[data-layout='split'] .er-media-text-grid {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto auto 1fr;
grid-template-areas: '. media' 'copy media' 'cta media' '. media';
row-gap: 0;
}
.er-media-text[data-layout='split'][data-reverse] .er-media-text-grid {
grid-template-areas: 'media .' 'media copy' 'media cta' 'media .';
}
.er-media-text[data-layout='split'] .er-media-text-cta {
margin-top: 1.5rem;
}
.er-media-text[data-layout='stacked'] .er-media-text-media {
aspect-ratio: var(--er-media-text-ratio-wide, 21 / 9);
}
/* Columns, middle width: copy beside the picture, the call to action across the bottom. */
.er-media-text[data-layout='columns'] .er-media-text-grid {
grid-template-columns: 1fr 1fr;
grid-template-areas: 'copy media' 'cta cta';
align-items: center;
row-gap: 2rem;
}
.er-media-text[data-layout='columns'][data-reverse] .er-media-text-grid {
grid-template-areas: 'media copy' 'cta cta';
}
}
@container (min-width: 56rem) {
.er-media-text[data-layout='columns'] .er-media-text-grid {
grid-template-columns: 1fr 1.2fr 1fr;
grid-template-areas: 'copy media cta';
}
.er-media-text[data-layout='columns'][data-reverse] .er-media-text-grid {
grid-template-areas: 'media copy cta';
}
.er-media-text[data-layout='columns'] .er-media-text-cta {
align-self: center;
padding-left: clamp(1.5rem, 3cqi, 2.5rem);
border-left: 1px solid var(--er-border);
}
}
}
</style>index.ts 4 lines
/** Media and text — a headline, body copy and a picture side by side, stacked, or in three columns with the call to action on its own. */
export { default as MediaText } from './MediaText.vue'
export * from './sample-data'sample-data.ts 40 lines
/**
* Made-up copy and a stand-in picture so the block has something to show. Replace this file with
* your own, or pass your own through the props of MediaText.vue.
*/
export interface MediaTextImage {
src: string
/** Say what the picture shows. Leave it empty only when the picture is pure decoration. */
alt: string
}
export interface MediaTextAction {
label: string
href: string
}
// A stand-in picture drawn as SVG, so the block needs no image files and no network.
export const sampleMediaImage: MediaTextImage = {
src: `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 160 120"><rect width="160" height="120" fill="#e9e2d6"/><circle cx="116" cy="44" r="12" fill="#d9a35b"/><path d="M0 92 46 50l30 26 22-18 62 50v12H0z" fill="#9aa88a"/><path d="M0 120V100l40-26 38 28 26-14 56 32z" fill="#6f7f63"/></svg>`,
)}`,
alt: 'Hills under a low sun',
}
export const sampleMediaText = {
eyebrow: 'Made to last',
title: 'Fired twice, built for every day',
body: 'Each piece is thrown by hand, then fired twice so the glaze sets hard. It goes in the dishwasher, the oven and the microwave, and it will still look the same in ten years.',
note: 'Free delivery on orders over $50.',
primaryAction: { label: 'Shop the collection', href: '#' },
secondaryAction: { label: 'How it is made', href: '#' },
} satisfies {
eyebrow: string
title: string
body: string
note: string
primaryAction: MediaTextAction
secondaryAction: MediaTextAction
}