Swish UI
All componentsText Free

Inline media headline

A headline with small media chips between its words that roll to new avatars, times and places every few seconds.

It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.

Demo.tsx

"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";

const PEOPLE = [["AN", "TK"], ["MR", "JS"], ["LP", "DO"]];
const TIMES = [[9, 30], [14, 0], [17, 45]];
const DAYS = [["Mon", 12], ["Wed", 14], ["Fri", 16]] as const;
const EVERY = 2200; // ms between rolls

const avatars = (i: number) => (
  <span className={styles.faces}>
    {PEOPLE[i].map((p, k) => <span key={p} className={styles.face} data-tone={(i + k) % 3}>{p}</span>)}
  </span>
);
const clock = (i: number) => {
  const [h, m] = TIMES[i];
  return (
    <span className={styles.pair}>
      <svg viewBox="0 0 16 16" className={styles.glyph}>
        <circle cx="8" cy="8" r="6.2" />
        <path d="M8 8V4.6" transform={`rotate(${((h % 12) + m / 60) * 30} 8 8)`} />
        <path d="M8 8V3" transform={`rotate(${m * 6} 8 8)`} />
      </svg>
      <span className={styles.time}>{`${h}:${String(m).padStart(2, "0")}`}</span>
    </span>
  );
};
const scene = (i: number) => <span className={styles.scene} data-scene={i} />;
const calendar = (i: number) => (
  <span className={styles.pair}>
    <svg viewBox="0 0 16 16" className={styles.glyph}>
      <rect x="2" y="3" width="12" height="11" rx="2.5" />
      <path d="M2 6.5h12M5.5 1.8v2.4M10.5 1.8v2.4" />
      <text x="8" y="12.4" textAnchor="middle">{DAYS[i][1]}</text>
    </svg>
    <span className={styles.time}>{DAYS[i][0]}</span>
  </span>
);

// One chip: a fixed size window where the outgoing item rolls up and the incoming one rolls in from below.
function Chip({ kind, index, tick, render }: { kind: string; index: number; tick: number; render: (i: number) => ReactNode }) {
  const shown = tick === 0 ? [0] : [tick - 1, tick];
  return (
    <span className={styles.chip} data-kind={kind} style={{ ["--stagger" as string]: `${index * 160}ms` }} aria-hidden="true">
      {shown.map((t) => (
        <span key={t} className={styles.item} data-roll={tick === 0 ? undefined : t === tick ? "in" : "out"}>
          {render(t % 3)}
        </span>
      ))}
    </span>
  );
}

export default function Demo() {
  const root = useRef<HTMLDivElement>(null);
  const inView = useInView(root);
  const reduced = usePrefersReducedMotion();
  const [tick, setTick] = useState(0);

  useEffect(() => {
    if (!inView || reduced) return;
    const timer = setInterval(() => setTick((t) => t + 1), EVERY);
    return () => clearInterval(timer);
  }, [inView, reduced]);

  return (
    <div ref={root} className={styles.root}>
      <h2 className={styles.title}>
        <span className={styles.group}>Meet <Chip kind="faces" index={0} tick={tick} render={avatars} /> your team</span>{" "}
        <span className={styles.group}><Chip kind="clock" index={1} tick={tick} render={clock} /> on time,</span>{" "}
        <span className={styles.group}>from <Chip kind="scene" index={2} tick={tick} render={scene} /> anywhere,</span>{" "}
        <span className={styles.group}><Chip kind="cal" index={3} tick={tick} render={calendar} /> every week.</span>
      </h2>
      <p className={styles.sub}>Scheduling that follows your team across time zones.</p>
    </div>
  );
}

demo.module.css

.root {
  container-type: size; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
  width: 100%; height: 100%; padding: 20px; box-sizing: border-box; background: var(--surface); text-align: center;
  --h: clamp(24px, 6.4cqi, 58px);
}
.title { margin: 0; max-width: 17em; font-size: var(--h); font-weight: 700; line-height: 1.28; letter-spacing: -0.03em; color: var(--ink); text-wrap: balance; }
.sub { margin: 0; font-size: clamp(13px, 2.2cqi, 16px); color: var(--muted); }
/* A chip and its neighbouring word never split across lines. */
.group { white-space: nowrap; }

.chip {
  position: relative; display: inline-block; overflow: hidden; vertical-align: -.12em; margin: 0 .06em;
  height: calc(var(--h) * .84); border-radius: 999px; background: var(--white); box-shadow: inset 0 0 0 1px var(--line);
  font-size: max(10px, calc(var(--h) * .3)); letter-spacing: 0; line-height: 1;
}
.chip[data-kind="faces"] { width: 5.4em; }
.chip[data-kind="clock"] { width: 5.4em; }
.chip[data-kind="scene"] { width: 4.4em; }
.chip[data-kind="cal"] { width: 5em; }
.item { position: absolute; inset: 0; display: grid; place-items: center; }

/* The roll: up and out with a little blur, the next one rises in behind it. --stagger offsets each chip. */
.item[data-roll="in"] { animation: roll-in .55s var(--out) var(--stagger) both; }
.item[data-roll="out"] { animation: roll-out .45s var(--out) var(--stagger) both; }
@keyframes roll-in { from { transform: translateY(75%); filter: blur(3px); opacity: 0; } }
@keyframes roll-out { to { transform: translateY(-75%); filter: blur(3px); opacity: 0; } }

.faces { display: flex; }
.face {
  display: grid; place-items: center; width: 2.3em; height: 2.3em; border-radius: 50%; font-size: .82em; font-weight: 700;
  box-shadow: 0 0 0 2px var(--white); background: var(--accent-pale); color: var(--accent-text);
}
.face + .face { margin-left: -.3em; }
.face[data-tone="1"] { background: var(--sand); color: var(--ink); }
.face[data-tone="2"] { background: var(--sky); color: var(--ink); }

.pair { display: inline-flex; align-items: center; gap: .35em; }
.glyph { width: 1.6em; height: 1.6em; fill: none; stroke: var(--accent-text); stroke-width: 1.5; stroke-linecap: round; }
.glyph text { fill: var(--ink); stroke: none; font-size: 6px; font-weight: 700; }
.time { font-size: 1.15em; font-weight: 700; color: var(--ink); font-variant-numeric: tabular-nums; }

/* Stand in photos drawn with gradients: a beach, a dusk ridge, a city at night. */
.scene { position: absolute; inset: 0; }
.scene[data-scene="0"] {
  background:
    radial-gradient(circle at 72% 34%, var(--white) 0 9%, transparent 10%),
    linear-gradient(var(--sky) 0 52%, var(--accent-soft) 52% 70%, var(--sand) 70%);
}
.scene[data-scene="1"] {
  background:
    radial-gradient(circle at 30% 60%, var(--accent) 0 12%, transparent 13%),
    conic-gradient(from 135deg at 62% 48%, var(--accent-text) 0 90deg, transparent 0) 0 0 / 100% 100% no-repeat,
    linear-gradient(var(--sand), var(--accent-pale));
}
.scene[data-scene="2"] {
  background:
    radial-gradient(circle, var(--accent) 0 1.4px, transparent 2px) 0 0 / .9em .7em,
    linear-gradient(transparent 60%, var(--accent-soft) 60%),
    linear-gradient(var(--accent-text), var(--ink));
}

@container (max-height: 270px) {
  .root { gap: 8px; --h: clamp(22px, 5.6cqi, 34px); }
  .sub { display: none; }
}
@container (max-width: 420px) {
  .root { --h: clamp(22px, 8.4cqi, 32px); }
}
@media (prefers-reduced-motion: reduce) {
  .item[data-roll] { animation: none; }
  .item[data-roll="out"] { display: none; }
}

Use it for

  • Hero headlines that promise breadth: people, places, times
  • Scheduling, travel and collaboration products
  • Giving a text only hero some imagery without a big picture

Avoid

  • Headlines where the chips carry meaning the words do not
  • More than four chips, the line stops reading as a sentence

How it works

One tick counter advances every 2.2 seconds while the headline is in view. Each chip is a fixed size window that renders the previous and current item: React keeps the old item's element and switches it to a roll out keyframe while the new one mounts with a roll in keyframe, both using translate, blur and opacity, and a CSS variable staggers the chips. The photos are layered CSS gradients and the clock hands are rotated SVG paths.

Accessibility

The chips are aria-hidden, so the heading reads as a plain sentence. Each chip and its neighbouring word are kept on one line for clean wrapping on phones. Reduced motion stops the cycling and shows the first set.

Found on