Elixir UI

Read More

ReadMore is an expandable text component that truncates long content to a configurable number of lines and reveals the full text with a smooth height animation.

Elixir UI is built for teams that want production-ready motion components without spending days polishing interactions from scratch. Every block is designed to feel intentional, from hover states and micro-animations to responsive behavior across devices.

The library follows a copy-paste friendly workflow: preview the component, install with the CLI, and customize styles directly in your codebase. Because everything is built with Tailwind and modern React patterns, it stays easy to scale as your design system grows.

Use ReadMore for long product descriptions, changelogs, and documentation callouts where context matters but screen space is limited. Users get a clean summary first, then can expand for the full detail only when they need it.

Installation

Install with CLI

Choose your package manager.

npx @praveenlodhi/elixir-ui add read-more
pnpm dlx @praveenlodhi/elixir-ui add read-more
yarn dlx @praveenlodhi/elixir-ui add read-more
bun x @praveenlodhi/elixir-ui add read-more

Install dependencies

npm install motion lucide-react class-variance-authority clsx
pnpm add motion lucide-react class-variance-authority clsx
yarn add motion lucide-react class-variance-authority clsx
bun add motion lucide-react class-variance-authority clsx

Add ReadMore component

Create a file at components/ui/read-more.tsx and paste the source.

components/ui/read-more.tsx
"use client";

import { useState } from "react";

import * as motion from "motion/react-client";
import clsx from "clsx";

import { cn } from "../lib/utils";
import { ActionButton } from "./action-button";

export interface ReadMoreProps {
  children: React.ReactNode;
  lines?: number;
  className?: string;
  duration?: number;
}

export function ReadMore({
  children,
  lines = 4,
  className,
  duration = 0.35,
}: ReadMoreProps) {
  const [open, setOpen] = useState(false);

  // ✅ FIXED: always convert to string
  const textContent = Array.isArray(children)
    ? children.join("")
    : String(children);

  const content = textContent.split("\n\n");

  return (
    <div className={cn(clsx("w-full", "space-y-5"), className)}>
      <motion.div
        initial={false}
        animate={{
          height: open ? "auto" : `${lines * 1.5}em`,
        }}
        transition={{ duration, ease: "easeInOut" }}
        className={cn("overflow-hidden")}
      >
        <div className={cn(clsx("whitespace-pre-line", "space-y-4"))}>
          {content.map((para, i) => (
            <p key={i}>{para}</p>
          ))}
        </div>
      </motion.div>

      <ActionButton
        variant="link"
        onClick={() => setOpen(!open)}
        className={cn(
          clsx("cursor-pointer", "rounded-none", "p-0", "text-blue-500")
        )}
      >
        {open ? "Read less" : "Read more"}
      </ActionButton>
    </div>
  );
}

Add ActionButton dependency

ReadMore uses ActionButton for the toggle control. Create a file at components/ui/action-button.tsx and paste the source.

components/ui/action-button.tsx
"use client";

import Link from "next/link";

import { cva, type VariantProps } from "class-variance-authority";
import clsx from "clsx";
import { ChevronDown } from "lucide-react";
import { motion } from "motion/react";

import { cn } from "../lib/utils";

const actionButtonVariants = cva(
  "group relative flex items-center justify-between overflow-hidden rounded-lg px-5 py-3 cursor-pointer text-sm font-medium transition-colors",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
        outline:
          "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

export interface ActionButtonProps {
  children?: React.ReactNode;
  icon?: React.ReactNode;
  href?: string;
  className?: string;
  onClick?: React.MouseEventHandler<HTMLButtonElement>;
}

export function ActionButton({
  children = "Button Name",
  icon = <ChevronDown size={20} />,
  variant = "default",
  href,
  className,
  onClick,
}: ActionButtonProps & VariantProps<typeof actionButtonVariants>) {
  const content = (
    <>
      {/* TEXT */}
      <div className="relative h-5 overflow-hidden">
        {/* original */}
        <motion.span
          variants={{
            rest: { y: 0 },
            hover: { y: "-100%" },
          }}
          transition={{ duration: 0.15, ease: "easeOut" }}
          className="block"
        >
          {children}
        </motion.span>

        {/* replica */}
        <motion.span
          variants={{
            rest: { y: "100%" },
            hover: { y: "0%" },
          }}
          transition={{ duration: 0.12, ease: "easeOut" }}
          className={cn(
            "absolute top-0 left-0 block",
            variant === "link" && "underline"
          )}
        >
          {children}
        </motion.span>
      </div>

      {/* ICON */}
      {icon && (
        <div className="relative ml-4 h-6 w-6 overflow-hidden">
          {/* original */}
          <motion.div
            variants={{
              rest: { y: 0 },
              hover: { y: "100%" },
            }}
            transition={{ duration: 0.18, ease: "easeOut" }}
            className="flex h-full w-full items-center justify-center"
          >
            {icon}
          </motion.div>

          {/* replica */}
          <motion.div
            variants={{
              rest: { y: "-100%" },
              hover: { y: "0%" },
            }}
            transition={{ duration: 0.18, ease: "easeOut" }}
            className="absolute top-0 left-0 flex h-full w-full items-center justify-center"
          >
            {icon}
          </motion.div>
        </div>
      )}
    </>
  );

  if (href) {
    return (
      <motion.div
        initial="rest"
        whileHover="hover"
        animate="rest"
        data-variant={variant}
        className={cn(
          clsx(actionButtonVariants({ variant })),
          !icon && "justify-center",
          className
        )}
      >
        <Link href={href} className="flex w-full items-center justify-between">
          {content}
        </Link>
      </motion.div>
    );
  }

  return (
    <motion.button
      initial="rest"
      whileHover="hover"
      animate="rest"
      onClick={onClick}
      data-variant={variant}
      className={cn(
        clsx(actionButtonVariants({ variant })),
        !icon && "justify-center",
        className
      )}
    >
      {content}
    </motion.button>
  );
}

Usage

import { ReadMore } from "@workspace/ui/components/read-more";

export function Demo() {
  return (
    <ReadMore lines={3} duration={0.3}>
      {`ReadMore keeps long content readable by default and lets users expand when
      they need full details. You can tune line count and animation duration to
      fit the tone of your interface. 
      
      Use it for product descriptions, changelogs, and anywhere you want to balance brevity 
      with context. The smooth height animation makes the transition feel natural, so 
      users can focus on the content instead of the mechanics of expanding and collapsing.`}
    </ReadMore>
  );
}

Props

Prop

Type

On this page