Web InnoventixFreeCode

Basic Divider

Original · free

basic horizontal dividers

byWeb InnoventixReact + Tailwind
divbasicdividers
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-basic.json
div-basic.tsx
"use client";

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

/* ------------------------------------------------------------------ */
/*  Types & option tables                                             */
/* ------------------------------------------------------------------ */

type StyleId =
  | "solid"
  | "dashed"
  | "dotted"
  | "gradient"
  | "labeled"
  | "ornament";
type WeightId = "hairline" | "regular" | "bold";
type AccentId = "slate" | "indigo" | "emerald" | "rose" | "amber";

interface Option<T extends string> {
  value: T;
  label: string;
  leading?: ReactNode;
}

const STYLE_OPTIONS: Option<StyleId>[] = [
  { value: "solid", label: "Solid" },
  { value: "dashed", label: "Dashed" },
  { value: "dotted", label: "Dotted" },
  { value: "gradient", label: "Gradient" },
  { value: "labeled", label: "Labeled" },
  { value: "ornament", label: "Ornament" },
];

const WEIGHT_OPTIONS: Option<WeightId>[] = [
  { value: "hairline", label: "Hairline" },
  { value: "regular", label: "Regular" },
  { value: "bold", label: "Bold" },
];

const WEIGHT_PX: Record<WeightId, number> = {
  hairline: 1,
  regular: 2,
  bold: 4,
};

const ACCENT_CLASS: Record<AccentId, string> = {
  slate: "text-slate-300 dark:text-slate-600",
  indigo: "text-indigo-400 dark:text-indigo-500",
  emerald: "text-emerald-400 dark:text-emerald-500",
  rose: "text-rose-400 dark:text-rose-500",
  amber: "text-amber-400 dark:text-amber-500",
};

const ACCENT_LABEL: Record<AccentId, string> = {
  slate: "Slate",
  indigo: "Indigo",
  emerald: "Emerald",
  rose: "Rose",
  amber: "Amber",
};

const ACCENT_SWATCH: Record<AccentId, string> = {
  slate: "bg-slate-400 dark:bg-slate-500",
  indigo: "bg-indigo-400 dark:bg-indigo-500",
  emerald: "bg-emerald-400 dark:bg-emerald-500",
  rose: "bg-rose-400 dark:bg-rose-500",
  amber: "bg-amber-400 dark:bg-amber-500",
};

const ACCENT_OPTIONS: Option<AccentId>[] = (
  Object.keys(ACCENT_LABEL) as AccentId[]
).map((id) => ({
  value: id,
  label: ACCENT_LABEL[id],
  leading: (
    <span
      aria-hidden="true"
      className={cn(
        "h-3 w-3 shrink-0 rounded-full ring-1 ring-black/5 dark:ring-white/10",
        ACCENT_SWATCH[id]
      )}
    />
  ),
}));

/* ------------------------------------------------------------------ */
/*  Utilities                                                         */
/* ------------------------------------------------------------------ */

function cn(...parts: (string | false | null | undefined)[]): string {
  return parts.filter(Boolean).join(" ");
}

/* ------------------------------------------------------------------ */
/*  Accessible segmented radiogroup                                   */
/* ------------------------------------------------------------------ */

interface SegmentedProps<T extends string> {
  label: string;
  options: Option<T>[];
  value: T;
  onChange: (next: T) => void;
  columnsClass?: string;
  hideLabelOnMobile?: boolean;
  reduce: boolean;
}

function Segmented<T extends string>({
  label,
  options,
  value,
  onChange,
  columnsClass,
  hideLabelOnMobile,
  reduce,
}: SegmentedProps<T>) {
  const groupId = useId();
  const labelId = `${groupId}-label`;
  const pillId = `${groupId}-pill`;
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  function move(index: number, delta: number) {
    const count = options.length;
    const next = (index + delta + count) % count;
    onChange(options[next].value);
    refs.current[next]?.focus();
  }

  function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, index: number) {
    switch (event.key) {
      case "ArrowRight":
      case "ArrowDown":
        event.preventDefault();
        move(index, 1);
        break;
      case "ArrowLeft":
      case "ArrowUp":
        event.preventDefault();
        move(index, -1);
        break;
      case "Home":
        event.preventDefault();
        onChange(options[0].value);
        refs.current[0]?.focus();
        break;
      case "End":
        event.preventDefault();
        onChange(options[options.length - 1].value);
        refs.current[options.length - 1]?.focus();
        break;
      default:
        break;
    }
  }

  return (
    <div>
      <span
        id={labelId}
        className="mb-2 block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400"
      >
        {label}
      </span>
      <div
        role="radiogroup"
        aria-labelledby={labelId}
        className={cn(
          "grid gap-1 rounded-2xl border border-slate-200 bg-slate-100/80 p-1 dark:border-slate-800 dark:bg-slate-800/60",
          columnsClass ?? "grid-flow-col auto-cols-fr"
        )}
      >
        {options.map((option, index) => {
          const selected = option.value === value;
          return (
            <button
              key={option.value}
              ref={(node) => {
                refs.current[index] = node;
              }}
              type="button"
              role="radio"
              aria-checked={selected}
              aria-label={option.label}
              tabIndex={selected ? 0 : -1}
              onClick={() => onChange(option.value)}
              onKeyDown={(event) => onKeyDown(event, index)}
              className={cn(
                "relative flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition-colors",
                "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-800",
                selected
                  ? "text-slate-900 dark:text-white"
                  : "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100"
              )}
            >
              {selected && (
                <motion.span
                  layoutId={pillId}
                  aria-hidden="true"
                  className="absolute inset-0 z-0 rounded-xl bg-white shadow-sm ring-1 ring-slate-900/5 dark:bg-slate-950 dark:ring-white/10"
                  transition={
                    reduce
                      ? { duration: 0 }
                      : { type: "spring", stiffness: 480, damping: 38 }
                  }
                />
              )}
              <span className="relative z-10 flex items-center gap-2">
                {option.leading}
                <span
                  className={cn(hideLabelOnMobile && "hidden sm:inline")}
                >
                  {option.label}
                </span>
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */
/*  Divider renderer                                                  */
/* ------------------------------------------------------------------ */

interface DividerProps {
  styleId: StyleId;
  weightPx: number;
  accentClass: string;
  labelText: string;
  shimmer: boolean;
}

function DividerLine({
  styleId,
  weightPx,
  accentClass,
  labelText,
  shimmer,
}: DividerProps) {
  const trimmed = labelText.trim();

  if (styleId === "solid") {
    return (
      <div
        role="separator"
        className={cn("w-full rounded-full bg-current", accentClass)}
        style={{ height: weightPx }}
      />
    );
  }

  if (styleId === "dashed" || styleId === "dotted") {
    return (
      <div
        role="separator"
        className={cn("w-full border-current", accentClass)}
        style={{
          borderTopWidth: weightPx,
          borderTopStyle: styleId === "dashed" ? "dashed" : "dotted",
        }}
      />
    );
  }

  if (styleId === "gradient") {
    return (
      <div
        role="separator"
        className={cn(
          "relative w-full overflow-hidden rounded-full",
          accentClass
        )}
        style={{
          height: Math.max(weightPx, 2),
          backgroundImage:
            "linear-gradient(90deg, transparent, currentColor 15%, currentColor 85%, transparent)",
        }}
      >
        {shimmer && (
          <span
            aria-hidden="true"
            className="divbasic-sheen absolute inset-y-0 left-0 w-1/4 bg-gradient-to-r from-transparent via-white/80 to-transparent dark:via-white/50"
          />
        )}
      </div>
    );
  }

  // labeled + ornament share the line/center/line structure
  const line = (
    <span
      aria-hidden="true"
      className={cn("h-px flex-1 rounded-full bg-current", accentClass)}
      style={{ height: weightPx }}
    />
  );

  const center =
    styleId === "ornament" ? (
      <span className={cn("shrink-0", accentClass)} aria-hidden="true">
        <svg
          width="20"
          height="20"
          viewBox="0 0 24 24"
          fill="none"
          stroke="currentColor"
          strokeWidth={1.4}
          strokeLinejoin="round"
        >
          <path d="M12 2.5 21.5 12 12 21.5 2.5 12Z" />
          <circle cx="12" cy="12" r="2.4" fill="currentColor" stroke="none" />
        </svg>
      </span>
    ) : (
      <span className="shrink-0 text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-slate-500 dark:text-slate-400">
        {trimmed.length > 0 ? trimmed : "Section"}
      </span>
    );

  return (
    <div
      role="separator"
      aria-label={
        styleId === "labeled" && trimmed.length > 0 ? trimmed : undefined
      }
      className="flex w-full items-center gap-4"
    >
      {line}
      {center}
      {line}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/*  Main component                                                    */
/* ------------------------------------------------------------------ */

export default function DivBasic() {
  const reduce = useReducedMotion() ?? false;

  const [styleId, setStyleId] = useState<StyleId>("gradient");
  const [weightId, setWeightId] = useState<WeightId>("regular");
  const [accentId, setAccentId] = useState<AccentId>("indigo");
  const [labelText, setLabelText] = useState<string>("Chapter two");

  const inputId = useId();

  const weightPx = WEIGHT_PX[weightId];
  const accentClass = ACCENT_CLASS[accentId];
  const shimmer = styleId === "gradient" && !reduce;
  const showLabelInput = styleId === "labeled" || styleId === "ornament";

  const summary = `${STYLE_OPTIONS.find((o) => o.value === styleId)?.label} · ${
    WEIGHT_OPTIONS.find((o) => o.value === weightId)?.label
  } · ${ACCENT_LABEL[accentId]}`;

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-20 text-slate-900 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100 sm:px-6 sm:py-24">
      <style>{`
        @keyframes divbasic-sheen {
          0%   { transform: translateX(-120%); }
          100% { transform: translateX(440%); }
        }
        .divbasic-sheen {
          animation: divbasic-sheen 2.8s ease-in-out infinite;
        }
        @media (prefers-reduced-motion: reduce) {
          .divbasic-sheen { animation: none; opacity: 0; }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        {/* Header */}
        <header className="mb-10">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-slate-500 shadow-sm dark:border-slate-800 dark:bg-slate-900 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Dividers · Basic
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
            A small kit of horizontal rules
          </h2>
          <p className="mt-3 max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-300">
            Six honest ways to separate two blocks of content. Change the style,
            weight, and tone, then watch the rule redraw itself in real time.
          </p>
        </header>

        {/* Controls */}
        <div className="grid gap-5 rounded-3xl border border-slate-200 bg-white/70 p-5 shadow-sm backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 sm:p-6">
          <Segmented
            label="Style"
            options={STYLE_OPTIONS}
            value={styleId}
            onChange={setStyleId}
            columnsClass="grid-cols-3 sm:grid-cols-6"
            reduce={reduce}
          />

          <div className="grid gap-5 sm:grid-cols-2">
            <Segmented
              label="Weight"
              options={WEIGHT_OPTIONS}
              value={weightId}
              onChange={setWeightId}
              columnsClass="grid-cols-3"
              reduce={reduce}
            />
            <Segmented
              label="Tone"
              options={ACCENT_OPTIONS}
              value={accentId}
              onChange={setAccentId}
              columnsClass="grid-cols-5"
              hideLabelOnMobile
              reduce={reduce}
            />
          </div>

          <div
            className={cn(
              "transition-opacity",
              showLabelInput ? "opacity-100" : "pointer-events-none opacity-40"
            )}
          >
            <label
              htmlFor={inputId}
              className="mb-2 block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400"
            >
              Label text
            </label>
            <input
              id={inputId}
              type="text"
              value={labelText}
              disabled={!showLabelInput}
              maxLength={28}
              onChange={(event: ChangeEvent<HTMLInputElement>) =>
                setLabelText(event.target.value)
              }
              placeholder="e.g. Chapter two"
              className="w-full rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 disabled:cursor-not-allowed dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100 dark:placeholder:text-slate-500"
            />
            <p className="mt-1.5 text-xs text-slate-400 dark:text-slate-500">
              Used by the{" "}
              <span className="font-medium text-slate-600 dark:text-slate-300">
                Labeled
              </span>{" "}
              style. The{" "}
              <span className="font-medium text-slate-600 dark:text-slate-300">
                Ornament
              </span>{" "}
              style draws a diamond instead.
            </p>
          </div>
        </div>

        {/* Live preview */}
        <div className="mt-8 overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900">
          <div className="flex items-center justify-between border-b border-slate-100 px-6 py-3 dark:border-slate-800">
            <span className="text-xs font-medium text-slate-500 dark:text-slate-400">
              Live preview
            </span>
            <span
              aria-live="polite"
              className="rounded-full bg-slate-100 px-2.5 py-1 font-mono text-[0.7rem] text-slate-600 dark:bg-slate-800 dark:text-slate-300"
            >
              {summary}
            </span>
          </div>

          <div className="px-6 py-8 sm:px-10 sm:py-10">
            <p className="text-[0.7rem] font-semibold uppercase tracking-[0.18em] text-indigo-500 dark:text-indigo-400">
              Overview
            </p>
            <h3 className="mt-2 text-xl font-semibold text-slate-900 dark:text-white">
              Separate ideas without stealing attention
            </h3>
            <p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
              A good divider is quiet. It marks the seam between two thoughts,
              gives the eye a place to rest, and then gets out of the way. The
              rule below is the exact element you would ship.
            </p>

            <motion.div
              key={`${styleId}-${weightId}-${accentId}`}
              initial={reduce ? false : { opacity: 0, scaleX: 0.94 }}
              animate={{ opacity: 1, scaleX: 1 }}
              transition={reduce ? { duration: 0 } : { duration: 0.4, ease: "easeOut" }}
              className="my-8 origin-center"
            >
              <DividerLine
                styleId={styleId}
                weightPx={weightPx}
                accentClass={accentClass}
                labelText={labelText}
                shimmer={shimmer}
              />
            </motion.div>

            <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
              Below the seam, the reader picks up a fresh section already primed
              for the shift. No heavy borders, no boxes, no shouting, just a
              clean horizontal line doing one job precisely.
            </p>
          </div>
        </div>

        <p className="mt-6 text-center text-xs text-slate-400 dark:text-slate-500">
          Use arrow keys inside any control to switch options. Every rule renders
          identically in light and dark.
        </p>
      </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 →