Hero
The first section of a page over a full-bleed picture or looping video: a headline on a thin rule, centred, or along the bottom.
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 heropnpm dlx ermineui add heroyarn dlx ermineui add herobunx ermineui add heroThat copies 3 files into src/components/blocks/hero/, and the 1 component it is built from. Files you already have are kept.
Use
import { Hero } from '@/components/blocks/hero'<Hero title="Field Studio" description="Architecture and interiors" :image="{ src: '/house.jpg' }" :primary-action="{ label: 'Get in touch', href: '/contact' }">
<template #header><SiteNav /></template>
</Hero>
<Hero
layout="bottom"
title="Houses for the way you live"
:video="{ src: [{ src: '/hero.webm', type: 'video/webm' }, { src: '/hero.mp4', type: 'video/mp4' }], poster: '/hero.jpg' }"
/>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 |
|---|---|
Hero.vue | src/components/blocks/hero/Hero.vue |
index.ts | src/components/blocks/hero/index.ts |
sample-data.ts | src/components/blocks/hero/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.
Hero.vue 446 lines
<script setup lang="ts">
import { computed, ref, useId, watch } from 'vue'
import { Button } from '../../ui/button'
import { sampleHero, sampleHeroImage, type HeroAction, type HeroImage, type HeroVideo } from './sample-data'
const props = withDefaults(
defineProps<{
/**
* `rule`: small type sitting on a thin line across the middle, the picture left to speak.
* `center`: a large headline in the middle. `bottom`: a large headline low on the left.
*/
layout?: 'rule' | 'center' | 'bottom'
/** The background picture. With `video` too, it is the poster unless the video has its own. */
image?: HeroImage | null
/** A background video: muted, looping, with a pause button. It does not play for readers who ask for less motion. */
video?: HeroVideo | null
eyebrow?: string
title?: string
description?: string
/** `null` leaves the action out. */
primaryAction?: HeroAction | null
secondaryAction?: HeroAction | null
/** `screen` fills the window, `large` three quarters of it, `auto` fits the content. Or set `--er-hero-height`. */
height?: 'screen' | 'large' | 'auto'
/** How much the picture is darkened under the text, from 0 to 1. Each layout has its own default. */
overlay?: number
/** Fade the picture towards the page colour as the hero scrolls away, where the browser supports scroll-driven animation. */
washOnScroll?: boolean
/** Heading level of the title. The hero usually holds the page's h1. */
titleAs?: string
pauseLabel?: string
playLabel?: string
}>(),
{
layout: 'rule',
image: () => sampleHeroImage,
eyebrow: sampleHero.eyebrow,
title: sampleHero.title,
description: sampleHero.description,
primaryAction: () => sampleHero.primaryAction,
secondaryAction: () => sampleHero.secondaryAction,
height: 'screen',
washOnScroll: true,
titleAs: 'h1',
pauseLabel: 'Pause background video',
playLabel: 'Play background video',
},
)
defineSlots<{
/** Along the top, over the picture: your logo and navigation. */
header?: () => unknown
/** More under the description. */
default?: () => unknown
/** A background of your own in place of the picture or video. */
media?: () => unknown
/** Links or buttons of your own in place of the two actions. */
actions?: () => unknown
}>()
const id = useId()
const videoEl = ref<HTMLVideoElement | null>(null)
const playing = ref(false)
const sources = computed(() => {
const src = props.video?.src
return typeof src === 'string' ? [{ src, type: undefined }] : (src ?? [])
})
const poster = computed(() => props.video?.poster ?? props.image?.src)
function toggle() {
const video = videoEl.value
if (!video) return
if (video.paused) video.play().catch(() => {})
else video.pause()
}
// Started here rather than with `autoplay`, so a reader who asked for less motion sees the poster instead.
watch(videoEl, (video) => {
if (!video || matchMedia('(prefers-reduced-motion: reduce)').matches) return
// A browser that blocks autoplay rejects this; the button then offers to play.
video.play().catch(() => {})
})
</script>
<template>
<section
class="er-hero"
:data-layout="layout"
:data-height="height"
:data-wash="washOnScroll || undefined"
:style="overlay === undefined ? undefined : { '--er-hero-overlay': overlay }"
:aria-labelledby="`${id}-title`"
>
<div class="er-hero-media">
<slot name="media">
<!-- The background is atmosphere: the words say what matters, so the video is hidden from screen readers. -->
<video
v-if="video"
ref="videoEl"
:key="sources.map((s) => s.src).join()"
:poster="poster"
muted
loop
playsinline
preload="metadata"
aria-hidden="true"
@play="playing = true"
@pause="playing = false"
>
<source v-for="source in sources" :key="source.src" :src="source.src" :type="source.type" />
</video>
<img v-else-if="image" :src="image.src" :srcset="image.srcset" :sizes="image.sizes" :alt="image.alt ?? ''" fetchpriority="high" />
</slot>
</div>
<div class="er-hero-scrim" />
<div class="er-hero-wash" />
<div class="er-hero-content">
<div v-if="$slots.header" class="er-hero-header"><slot name="header" /></div>
<div class="er-hero-body">
<div class="er-hero-heading">
<p v-if="eyebrow" class="er-hero-eyebrow">{{ eyebrow }}</p>
<component :is="titleAs" :id="`${id}-title`" class="er-hero-title">{{ title }}</component>
</div>
<div v-if="description || $slots.default" class="er-hero-description">
<p v-if="description">{{ description }}</p>
<slot />
</div>
<div v-if="primaryAction || secondaryAction || $slots.actions" class="er-hero-actions">
<slot name="actions">
<!-- On the rule the actions are quiet links, like the rest of its type; elsewhere they are buttons. -->
<template v-if="layout === 'rule'">
<a v-if="primaryAction" class="er-hero-link" :href="primaryAction.href">{{ primaryAction.label }}</a>
<a v-if="secondaryAction" class="er-hero-link" :href="secondaryAction.href">{{ secondaryAction.label }}</a>
</template>
<template v-else>
<Button v-if="primaryAction" as="a" size="lg" :href="primaryAction.href">{{ primaryAction.label }}</Button>
<Button v-if="secondaryAction" as="a" size="lg" variant="outline" :href="secondaryAction.href">{{ secondaryAction.label }}</Button>
</template>
</slot>
</div>
</div>
<button
v-if="video && !$slots.media"
type="button"
class="er-hero-toggle"
:aria-label="playing ? pauseLabel : playLabel"
@click="toggle"
>
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path v-if="playing" d="M7 5h3.500v14H7zm6.500 0H17v14h-3.500z" />
<path v-else d="M8 5.500v13l10.500-6.500z" />
</svg>
</button>
</div>
</section>
</template>
<style>
@layer components {
.er-hero {
--er-hero-overlay: 0.15;
position: relative;
isolation: isolate;
display: grid;
min-height: var(--er-hero-height, 100svh);
overflow: clip;
container-type: inline-size;
background: var(--er-muted);
}
.er-hero[data-height='large'] {
--er-hero-height: 75svh;
}
.er-hero[data-height='auto'] {
--er-hero-height: auto;
}
.er-hero-media,
.er-hero-scrim,
.er-hero-wash {
position: absolute;
inset: 0;
z-index: -1;
}
.er-hero-media > :is(img, video, picture, iframe) {
display: block;
width: 100%;
height: 100%;
border: 0;
object-fit: cover;
}
.er-hero-scrim {
background: color-mix(in oklab, var(--er-scrim) calc(var(--er-hero-overlay) * 100%), transparent);
}
.er-hero-wash {
background: var(--er-background);
opacity: 0;
}
/* Over a picture the text is always light, whatever the page's theme: buttons and focus rings follow. */
.er-hero-content {
color-scheme: dark;
display: grid;
grid-template-rows: auto 1fr auto;
--er-hero-gutter: 1.25rem;
min-height: inherit;
padding: var(--er-hero-gutter);
color: var(--er-foreground);
}
.er-hero-header {
grid-row: 1;
}
.er-hero-body {
grid-row: 2;
display: grid;
gap: 1rem;
align-content: center;
}
.er-hero-heading {
display: grid;
gap: 0.5rem;
}
.er-hero-eyebrow {
margin: 0;
font-size: 0.8125rem;
font-weight: var(--er-weight-medium);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.er-hero-title {
margin: 0;
font-family: var(--er-font-display);
font-size: clamp(2.5rem, 8cqi, 6rem);
font-weight: var(--er-weight-semibold);
letter-spacing: calc(-0.035em * var(--er-tracking-heading));
text-transform: var(--er-display-case);
line-height: 1;
text-wrap: balance;
}
.er-hero-description {
display: grid;
gap: 0.75rem;
max-width: 36rem;
font-family: var(--er-font-prose);
font-size: clamp(1rem, 1.6cqi, 1.25rem);
line-height: 1.5;
text-wrap: pretty;
}
.er-hero-description > * {
margin: 0;
}
.er-hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.5rem;
}
.er-hero-toggle {
grid-row: 3;
justify-self: end;
display: grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
margin-top: 1rem;
border: 1px solid color-mix(in oklab, var(--er-foreground) 40%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--er-scrim) 25%, transparent);
color: var(--er-foreground);
cursor: pointer;
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.er-hero-toggle:hover {
background: color-mix(in oklab, var(--er-scrim) 45%, transparent);
}
.er-hero-toggle:focus-visible,
.er-hero-link:focus-visible {
outline: 2px solid var(--er-ring);
outline-offset: 2px;
}
.er-hero-toggle svg {
width: 1rem;
height: 1rem;
}
/* Centre: one large statement in the middle of the picture. */
.er-hero[data-layout='center'] {
--er-hero-overlay: 0.35;
}
.er-hero[data-layout='center'] .er-hero-body {
justify-items: center;
text-align: center;
}
.er-hero[data-layout='center'] .er-hero-heading {
justify-items: center;
}
.er-hero[data-layout='center'] .er-hero-actions {
justify-content: center;
}
/* Bottom: the headline low on the left, over a shade that darkens towards the foot. */
.er-hero[data-layout='bottom'] {
--er-hero-overlay: 0.6;
}
.er-hero[data-layout='bottom'] .er-hero-scrim {
background: linear-gradient(
to top,
color-mix(in oklab, var(--er-scrim) calc(var(--er-hero-overlay) * 100%), transparent),
color-mix(in oklab, var(--er-scrim) calc(var(--er-hero-overlay) * 30%), transparent) 45%,
transparent 75%
);
}
.er-hero[data-layout='bottom'] .er-hero-body {
align-content: end;
max-width: 50rem;
padding-top: 6rem;
}
/*
* Rule: a hairline across the middle with small type on it, in columns on a wide screen.
* The picture is barely darkened, so a soft shadow keeps the text off the brightest parts.
*/
.er-hero[data-layout='rule'] .er-hero-content {
padding-inline: 0;
text-shadow: 0 1px 12px color-mix(in oklab, var(--er-scrim) 35%, transparent);
}
.er-hero[data-layout='rule'] :is(.er-hero-header, .er-hero-body) {
padding-inline: var(--er-hero-gutter);
}
.er-hero[data-layout='rule'] .er-hero-toggle {
margin-right: var(--er-hero-gutter);
}
.er-hero[data-layout='rule'] .er-hero-body {
align-self: center;
align-items: start;
gap: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid color-mix(in oklab, var(--er-foreground) 85%, transparent);
font-size: 0.9375rem;
}
.er-hero[data-layout='rule'] .er-hero-heading {
gap: 0.25rem;
}
.er-hero[data-layout='rule'] .er-hero-eyebrow {
font-size: 0.75rem;
}
.er-hero[data-layout='rule'] .er-hero-title {
font-size: clamp(1.125rem, 2cqi, 1.5rem);
font-weight: 400;
letter-spacing: 0.04em;
line-height: 1.2;
text-transform: uppercase;
}
.er-hero[data-layout='rule'] .er-hero-description {
font-size: inherit;
}
.er-hero[data-layout='rule'] .er-hero-actions {
flex-direction: column;
gap: 0.25rem;
margin-top: 0;
}
.er-hero-link {
color: inherit;
text-decoration: underline;
text-decoration-color: transparent;
text-underline-offset: 0.25em;
transition: text-decoration-color var(--er-duration) var(--er-ease);
}
.er-hero-link:hover {
text-decoration-color: currentColor;
}
@container (min-width: 48rem) {
.er-hero-content {
--er-hero-gutter: 2rem;
}
.er-hero[data-layout='rule'] .er-hero-body {
grid-template-columns: 2fr 1fr 1fr;
gap: 2rem;
}
.er-hero[data-layout='rule'] .er-hero-actions {
grid-column: 3;
}
.er-hero[data-layout='bottom'] .er-hero-content {
padding-block: 2rem 2.5rem;
}
}
/* As the hero leaves the top of the window, the picture fades towards the page. */
@supports (animation-timeline: view()) {
@media (prefers-reduced-motion: no-preference) {
.er-hero[data-wash] .er-hero-wash {
animation: er-hero-wash linear both;
animation-timeline: view();
animation-range: exit 0% exit 80%;
}
}
}
@keyframes er-hero-wash {
to {
opacity: 0.75;
}
}
}
</style>index.ts 4 lines
/** Hero — the first section of a page over a full-bleed picture or looping video: a headline on a thin rule, centred, or along the bottom. */
export { default as Hero } from './Hero.vue'
export * from './sample-data'sample-data.ts 68 lines
/**
* A stand-in picture and made-up copy so the hero has something to show. Replace this file with
* your own, or pass your own through the props of Hero.vue.
*/
export interface HeroImage {
src: string
/** Leave empty when the picture is only atmosphere, which a hero background usually is. */
alt?: string
srcset?: string
sizes?: string
}
export interface HeroVideo {
/** One file, or several formats for the browser to choose from, best first. */
src: string | Array<{ src: string; type: string }>
/** Shown while the video loads, and instead of it when the reader has asked for less motion. */
poster?: string
}
export interface HeroAction {
label: string
href: string
}
// A house among trees, drawn as SVG so the block needs no image files and no network.
const scene = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#9fc0da"/><stop offset=".55" stop-color="#d9e4ea"/><stop offset="1" stop-color="#eef0ec"/></linearGradient>
<linearGradient id="wall" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#cdbb9f"/><stop offset="1" stop-color="#bba98c"/></linearGradient>
<linearGradient id="glass" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#5d6f78"/><stop offset=".6" stop-color="#2e3a40"/><stop offset="1" stop-color="#46545a"/></linearGradient>
</defs>
<rect width="1600" height="1000" fill="url(#sky)"/>
<circle cx="1220" cy="190" r="70" fill="#fbf3dc" opacity=".8"/>
<path d="M0 640c180-60 340-80 520-60s360 40 560 0 360-50 520-20v440H0z" fill="#a9b8a0"/>
<rect x="640" y="250" width="760" height="560" fill="url(#wall)"/>
<rect x="640" y="250" width="760" height="560" fill="#000" opacity=".04"/>
<rect x="300" y="470" width="420" height="340" fill="#e4dccd"/>
<rect x="250" y="440" width="560" height="44" fill="#f1ece2"/>
<rect x="250" y="484" width="560" height="10" fill="#000" opacity=".12"/>
<rect x="340" y="520" width="330" height="230" fill="url(#glass)"/>
<path d="M340 520h330L340 700z" fill="#fff" opacity=".08"/>
<rect x="502" y="520" width="6" height="230" fill="#e9e3d8"/>
<rect x="1010" y="440" width="190" height="370" fill="#efe8dc"/>
<rect x="1040" y="470" width="130" height="340" fill="#3a3430"/>
<rect x="1040" y="470" width="130" height="340" fill="#e8c99a" opacity=".22"/>
<path d="M0 810h1600v190H0z" fill="#7f9270"/>
<path d="M560 1000l170-190h260L900 1000z" fill="#e7e1d6"/>
<g fill="#5f7a4f"><circle cx="120" cy="420" r="170"/><circle cx="260" cy="330" r="150"/><circle cx="40" cy="250" r="120"/><circle cx="1500" cy="360" r="150"/><circle cx="1600" cy="260" r="130"/></g>
<g fill="#7d9866" opacity=".8"><circle cx="200" cy="300" r="90"/><circle cx="1460" cy="300" r="80"/></g>
<rect x="1450" y="420" width="34" height="420" fill="#cbb9a2"/>
<rect x="180" y="460" width="30" height="380" fill="#8a7a66"/>
<g fill="#4c6440"><ellipse cx="420" cy="860" rx="190" ry="70"/><ellipse cx="1180" cy="870" rx="230" ry="80"/><ellipse cx="80" cy="900" rx="200" ry="90"/><ellipse cx="1560" cy="900" rx="160" ry="90"/></g>
</svg>`
export const sampleHeroImage: HeroImage = {
src: `data:image/svg+xml,${encodeURIComponent(scene)}`,
alt: '',
}
export const sampleHero = {
eyebrow: 'Los Angeles',
title: 'Field Studio',
description: 'Architecture and interiors',
primaryAction: { label: 'Schedule a conversation', href: '#' },
secondaryAction: { label: 'See the work', href: '#' },
} satisfies { eyebrow: string; title: string; description: string; primaryAction: HeroAction; secondaryAction: HeroAction }