Web InnoventixFreeCode

Fancy Divider

Original · free

decorative ornament divider

byWeb InnoventixReact + Tailwind
divfancydividers
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/div-fancy.json
div-fancy.tsx
"use client";

import { useId, useRef, useState, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";

type MotifId = "filigree" | "fan" | "compass" | "laurel" | "quatrefoil";

const MOTIFS: ReadonlyArray<{ id: MotifId; name: string; blurb: string }> = [
  {
    id: "filigree",
    name: "Filigree",
    blurb: "Curling flourishes drawn around a cut lozenge — the printer's classic breather.",
  },
  {
    id: "fan",
    name: "Deco Fan",
    blurb: "Eight rays fanning from a struck center, in the stepped art-deco manner.",
  },
  {
    id: "compass",
    name: "Compass",
    blurb: "An eight-point star inside its ring — a small heading for the lost.",
  },
  {
    id: "laurel",
    name: "Laurel",
    blurb: "Twin sprigs mirrored leaf for leaf, meeting at a single berry.",
  },
  {
    id: "quatrefoil",
    name: "Quatrefoil",
    blurb: "A four-lobed medallion borrowed from the cloister windows.",
  },
];

function Emblem({
  id,
  className,
  label,
}: {
  id: MotifId;
  className?: string;
  label?: string;
}) {
  const shared = {
    viewBox: "0 0 100 56",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.5,
    strokeLinecap: "round" as const,
    strokeLinejoin: "round" as const,
    className,
    role: label ? "img" : undefined,
    "aria-label": label,
    "aria-hidden": label ? undefined : true,
  };

  if (id === "filigree") {
    return (
      <svg {...shared}>
        <path d="M50 14 L64 28 L50 42 L36 28 Z" />
        <path d="M50 20 L58 28 L50 36 L42 28 Z" opacity={0.55} />
        <circle cx={50} cy={28} r={2} fill="currentColor" stroke="none" />
        <path d="M36 28 C 26 18, 18 38, 9 28" />
        <path d="M64 28 C 74 18, 82 38, 91 28" />
        <circle cx={9} cy={28} r={1.6} fill="currentColor" stroke="none" />
        <circle cx={91} cy={28} r={1.6} fill="currentColor" stroke="none" />
      </svg>
    );
  }

  if (id === "fan") {
    return (
      <svg {...shared}>
        <path d="M50 10 L70 28 L50 46 L30 28 Z" />
        <path
          d="M50 28 L50 10 M50 28 L60 17 M50 28 L70 28 M50 28 L60 39 M50 28 L50 46 M50 28 L40 39 M50 28 L30 28 M50 28 L40 17"
          opacity={0.7}
        />
        <circle cx={50} cy={28} r={2.5} fill="currentColor" stroke="none" />
        <path d="M30 28 L16 28 M70 28 L84 28" opacity={0.5} />
        <circle cx={16} cy={28} r={1.5} fill="currentColor" stroke="none" />
        <circle cx={84} cy={28} r={1.5} fill="currentColor" stroke="none" />
      </svg>
    );
  }

  if (id === "compass") {
    return (
      <svg {...shared}>
        <circle cx={50} cy={28} r={21} opacity={0.3} />
        <polygon points="50,10 52.7,21.5 62.7,15.3 56.5,25.3 68,28 56.5,30.7 62.7,40.7 52.7,34.5 50,46 47.3,34.5 37.3,40.7 43.5,30.7 32,28 43.5,25.3 37.3,15.3 47.3,21.5" />
        <circle cx={50} cy={28} r={2.5} fill="currentColor" stroke="none" />
      </svg>
    );
  }

  if (id === "laurel") {
    return (
      <svg {...shared}>
        <circle cx={50} cy={28} r={2} fill="currentColor" stroke="none" />
        <path d="M47 28 C 40 27, 32 24, 24 27" />
        <path d="M53 28 C 60 27, 68 24, 76 27" />
        <ellipse cx={41} cy={25} rx={3.4} ry={1.5} transform="rotate(-32 41 25)" />
        <ellipse cx={34} cy={23.5} rx={3.4} ry={1.5} transform="rotate(-32 34 23.5)" />
        <ellipse cx={27} cy={23.5} rx={3} ry={1.4} transform="rotate(-32 27 23.5)" />
        <ellipse cx={59} cy={25} rx={3.4} ry={1.5} transform="rotate(32 59 25)" />
        <ellipse cx={66} cy={23.5} rx={3.4} ry={1.5} transform="rotate(32 66 23.5)" />
        <ellipse cx={73} cy={23.5} rx={3} ry={1.4} transform="rotate(32 73 23.5)" />
      </svg>
    );
  }

  return (
    <svg {...shared}>
      <circle cx={50} cy={17} r={8} />
      <circle cx={62} cy={28} r={8} />
      <circle cx={50} cy={39} r={8} />
      <circle cx={38} cy={28} r={8} />
      <path d="M50 24 L54 28 L50 32 L46 28 Z" fill="currentColor" stroke="none" />
    </svg>
  );
}

function Corner({ className }: { className: string }) {
  return (
    <svg
      aria-hidden
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.25}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
    >
      <path d="M2 11 L2 2 L11 2" />
      <path d="M2 2 C 8 4, 10 6, 12 12" opacity={0.5} />
    </svg>
  );
}

function Rule({ side, gleam }: { side: "left" | "right"; gleam: boolean }) {
  const base =
    side === "left"
      ? "from-transparent via-zinc-300 to-zinc-400 dark:via-zinc-700 dark:to-zinc-600"
      : "from-zinc-400 via-zinc-300 to-transparent dark:from-zinc-600 dark:via-zinc-700";
  return (
    <span className="relative h-px flex-1 overflow-hidden rounded-full">
      <span className={`absolute inset-0 bg-gradient-to-r ${base}`} />
      {gleam ? (
        <span
          aria-hidden
          className="dvfy-gleam absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-amber-400 to-transparent dark:via-amber-300"
          style={{ animationDelay: side === "left" ? "0s" : "1.4s" }}
        />
      ) : null}
    </span>
  );
}

export default function DivFancy() {
  const shouldReduce = useReducedMotion();
  const canAnimate = !shouldReduce;

  const [selected, setSelected] = useState<MotifId>("filigree");
  const [spread, setSpread] = useState<number>(58);
  const [animate, setAnimate] = useState<boolean>(true);

  const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
  const spreadId = useId();
  const switchLabelId = useId();

  const gleamOn = animate && canAnimate;
  const maxW = 22 + (spread / 100) * 24;
  const current = MOTIFS.find((m) => m.id === selected) ?? MOTIFS[0];

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const idx = MOTIFS.findIndex((m) => m.id === selected);
    const len = MOTIFS.length;
    let next = idx;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % len;
    else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + len) % len;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = len - 1;
    else return;
    e.preventDefault();
    setSelected(MOTIFS[next].id);
    radioRefs.current[next]?.focus();
  };

  const rise = (delay: number) =>
    shouldReduce
      ? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.4, delay } }
      : {
          initial: { opacity: 0, y: 18 },
          animate: { opacity: 1, y: 0 },
          transition: { duration: 0.6, delay, ease: "easeOut" as const },
        };

  const focusRing =
    "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950";

  return (
    <section className="relative w-full overflow-hidden bg-zinc-50 px-6 py-20 text-zinc-900 sm:py-28 dark:bg-zinc-950 dark:text-zinc-100">
      <style>{`
        @keyframes dvfy-gleam {
          from { transform: translateX(-160%); }
          to { transform: translateX(460%); }
        }
        @keyframes dvfy-emblem {
          0%, 100% { transform: scale(1); }
          50% { transform: scale(1.05); }
        }
        .dvfy-gleam { animation: dvfy-gleam 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite; }
        .dvfy-emblem { animation: dvfy-emblem 3.6s ease-in-out infinite; transform-origin: center; }
        @media (prefers-reduced-motion: reduce) {
          .dvfy-gleam, .dvfy-emblem { animation: none !important; }
        }
      `}</style>

      <div aria-hidden className="pointer-events-none absolute inset-0">
        <div className="absolute left-1/2 top-4 h-72 w-[42rem] max-w-full -translate-x-1/2 rounded-full bg-amber-200/40 blur-3xl dark:bg-amber-500/10" />
        <div className="absolute bottom-0 left-1/2 h-64 w-[36rem] max-w-full -translate-x-1/2 rounded-full bg-rose-200/20 blur-3xl dark:bg-rose-500/5" />
      </div>

      <div className="relative mx-auto max-w-3xl">
        <motion.header {...rise(0)} className="text-center">
          <span className="inline-flex items-center gap-2 text-[0.7rem] font-semibold uppercase tracking-[0.35em] text-amber-700 dark:text-amber-500">
            <span className="h-1.5 w-1.5 rotate-45 bg-amber-500 dark:bg-amber-400" aria-hidden />
            Divider Studio · Typographic Rules
            <span className="h-1.5 w-1.5 rotate-45 bg-amber-500 dark:bg-amber-400" aria-hidden />
          </span>
          <h2 className="mt-5 font-serif text-4xl font-medium tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-50">
            Ornamental Dividers
          </h2>
          <p className="mx-auto mt-5 max-w-xl text-pretty text-base leading-relaxed text-zinc-600 dark:text-zinc-300">
            Five separators drawn by hand for the pauses in a page — the end of a chapter,
            the turn of a thought, the breath between two scenes. Choose a motif, widen the
            spread, and let the gleam catch the rule.
          </p>
        </motion.header>

        <motion.figure
          {...rise(0.1)}
          className="relative mt-14 rounded-2xl border border-zinc-200 bg-white/70 px-6 py-14 shadow-sm backdrop-blur-sm sm:px-12 dark:border-zinc-800 dark:bg-zinc-900/60"
        >
          <Corner className="absolute left-3 top-3 h-5 w-5 text-amber-500/70 dark:text-amber-400/60" />
          <Corner className="absolute right-3 top-3 h-5 w-5 rotate-90 text-amber-500/70 dark:text-amber-400/60" />
          <Corner className="absolute bottom-3 right-3 h-5 w-5 rotate-180 text-amber-500/70 dark:text-amber-400/60" />
          <Corner className="absolute bottom-3 left-3 h-5 w-5 -rotate-90 text-amber-500/70 dark:text-amber-400/60" />

          <p className="mx-auto max-w-md text-pretty text-center font-serif text-lg italic leading-relaxed text-zinc-600 dark:text-zinc-300">
            &ldquo;And so the harbor lights fell away behind them, one by one, until only the
            black water remained — patient, and enormous.&rdquo;
          </p>

          <div
            className="mx-auto my-10 flex w-full items-center gap-3 sm:gap-4"
            style={{ maxWidth: `${maxW}rem` }}
          >
            <Rule side="left" gleam={gleamOn} />
            <span
              className="h-1.5 w-1.5 shrink-0 rotate-45 bg-amber-500/80 dark:bg-amber-400/80"
              aria-hidden
            />
            <Emblem
              id={selected}
              label={`${current.name} ornament divider`}
              className={`h-12 w-auto shrink-0 text-amber-600 dark:text-amber-400 ${
                gleamOn ? "dvfy-emblem" : ""
              }`}
            />
            <span
              className="h-1.5 w-1.5 shrink-0 rotate-45 bg-amber-500/80 dark:bg-amber-400/80"
              aria-hidden
            />
            <Rule side="right" gleam={gleamOn} />
          </div>

          <figcaption className="text-center">
            <span className="block text-[0.7rem] font-semibold uppercase tracking-[0.35em] text-amber-700 dark:text-amber-500">
              Chapter Nine
            </span>
            <span className="mt-3 block font-serif text-2xl font-medium text-zinc-900 dark:text-zinc-100">
              The Cartographer&rsquo;s Apology
            </span>
          </figcaption>
        </motion.figure>

        <motion.div
          {...rise(0.2)}
          className="mt-10 rounded-2xl border border-zinc-200 bg-white/60 p-6 shadow-sm backdrop-blur-sm sm:p-8 dark:border-zinc-800 dark:bg-zinc-900/50"
        >
          <div className="flex items-baseline justify-between">
            <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Motif</h3>
            <span className="text-xs text-zinc-500 dark:text-zinc-400">
              Arrow keys to browse
            </span>
          </div>

          <div
            role="radiogroup"
            aria-label="Divider motif"
            onKeyDown={onKeyDown}
            className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-5"
          >
            {MOTIFS.map((m, i) => {
              const active = m.id === selected;
              return (
                <button
                  key={m.id}
                  type="button"
                  role="radio"
                  aria-checked={active}
                  tabIndex={active ? 0 : -1}
                  ref={(el) => {
                    radioRefs.current[i] = el;
                  }}
                  onClick={() => setSelected(m.id)}
                  className={`group flex flex-col items-center rounded-xl border px-2 py-3 transition-colors ${focusRing} ${
                    active
                      ? "border-amber-400 bg-amber-50 text-amber-700 dark:border-amber-500/60 dark:bg-amber-500/10 dark:text-amber-300"
                      : "border-zinc-200 bg-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:border-zinc-800 dark:text-zinc-400 dark:hover:border-zinc-700 dark:hover:text-zinc-200"
                  }`}
                >
                  <Emblem
                    id={m.id}
                    className={`h-7 w-auto ${
                      active
                        ? "text-amber-600 dark:text-amber-400"
                        : "text-zinc-400 group-hover:text-zinc-600 dark:text-zinc-500 dark:group-hover:text-zinc-300"
                    }`}
                  />
                  <span className="mt-2 block text-xs font-medium">{m.name}</span>
                </button>
              );
            })}
          </div>

          <p
            aria-live="polite"
            className="mt-4 min-h-[2.5rem] text-sm leading-relaxed text-zinc-600 dark:text-zinc-300"
          >
            {current.blurb}
          </p>

          <hr className="my-6 border-zinc-200 dark:border-zinc-800" />

          <div className="grid gap-8 sm:grid-cols-2 sm:items-center">
            <div>
              <div className="flex items-center justify-between">
                <label
                  htmlFor={spreadId}
                  className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
                >
                  Spread
                </label>
                <span className="text-xs tabular-nums text-zinc-500 dark:text-zinc-400">
                  {maxW.toFixed(1)} rem
                </span>
              </div>
              <input
                id={spreadId}
                type="range"
                min={0}
                max={100}
                step={1}
                value={spread}
                onChange={(e) => setSpread(Number(e.target.value))}
                aria-valuetext={`${maxW.toFixed(1)} rem wide`}
                className={`mt-3 w-full cursor-pointer accent-amber-500 ${focusRing} rounded-full`}
              />
            </div>

            <div className="flex items-center justify-between gap-4">
              <span
                id={switchLabelId}
                className="text-sm font-medium text-zinc-900 dark:text-zinc-100"
              >
                Gleam
                <span className="mt-0.5 block text-xs font-normal text-zinc-500 dark:text-zinc-400">
                  {canAnimate
                    ? "Animated highlight sweeps the rule"
                    : "Disabled — system prefers reduced motion"}
                </span>
              </span>
              <button
                type="button"
                role="switch"
                aria-checked={gleamOn}
                aria-labelledby={switchLabelId}
                disabled={!canAnimate}
                onClick={() => setAnimate((a) => !a)}
                className={`relative h-6 w-11 shrink-0 rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${focusRing} ${
                  gleamOn ? "bg-amber-500" : "bg-zinc-300 dark:bg-zinc-700"
                }`}
              >
                <span
                  aria-hidden
                  className={`absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform dark:bg-zinc-100 ${
                    gleamOn ? "translate-x-5" : "translate-x-0"
                  }`}
                />
              </button>
            </div>
          </div>
        </motion.div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →