Skip to content

A Video Description Box With a Show More That Earns Its Place

Adityo Guni Waluyo

A three-line clamp plus a post-mount overflow measurement keeps the show-more toggle honest: it renders only when text is truly cut.

TL;DR

KotaPortal's video descriptions could grow so long that Like and Share buttons fell below the fold. Instead of truncating by character count, the component clamps text to three lines with Tailwind's line-clamp-3, then measures scrollHeight against clientHeight after mount to show a toggle only when needed. The "Deskripsi" label was dropped since the box and toggle make it redundant.

# A Video Description Box Whose "Show More" Appears Only When It Has To

On the video detail page of the KotaPortal portal, the description sits directly above the action buttons. The field is free text: the uploader can paste two hundred lines of credits and nobody stops them. The old UI rendered all of it as one plain block under the label "Deskripsi", always open. On videos with long descriptions the Like and Share buttons ended up below the fold, and the only way to reach them was to scroll past the entire wall of text.

The first guess: truncate by character count

The obvious fix looked like a server-side slice: take the first 300 characters of the description, add an ellipsis, done.

It breaks in three places. A slice cuts mid-word and sometimes mid-surrogate, so words come out shredded. The cut does not match the visible width anyway, because 300 characters of narrow Latin text occupy far less space than 300 characters of wide glyphs or of text in a large font. And the moment the reader expands the box, the full text has to come back from somewhere, so the component ends up rendering twice or re-fetching the rest.

The better finding: leave the text intact and let CSS clamp it to three lines, then measure the real element after mount and decide whether a toggle is even needed.

Clamp in CSS, measure after mount

The line-clamp property limits a block to N lines. It requires overflow: hidden or the content will not be clipped [4]. The fully specified behavior still ships with the legacy spelling: display: -webkit-box, -webkit-box-orient: vertical, and -webkit-line-clamp [4]. Tailwind wraps all of that into two utilities: line-clamp-3 applies the clamp, line-clamp-none undoes it [6]. No custom CSS is needed.

The measurement side is Element.scrollHeight. It reports the content height including content hidden by overflow [5]. When the text fits the visible area, scrollHeight equals clientHeight [5]. So the test is scrollHeight > clientHeight, compared with a one-pixel threshold to absorb subpixel rounding.

"use client";

import { useEffect, useRef, useState } from "react";

export function VideoDescription({ text }: { text: string }) {
  const [expanded, setExpanded] = useState(false);
  const [truncatable, setTruncatable] = useState(false);
  const ref = useRef<HTMLParagraphElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    // +1 menahan perbedaan pembulatan subpiksel [5]
    setTruncatable(el.scrollHeight > el.clientHeight + 1);
    // ponytail: ukur ulang hanya saat text berubah; jika viewport
    // bisa berubah saat expanded, tambahkan ResizeObserver.
  }, [text]);

  return (
    <div
      data-testid="video-description-box"
      className="rounded-lg bg-gray-100 p-3 text-sm"
    >
      <p
        ref={ref}
        data-testid="video-description"
        className={
          expanded
            ? "line-clamp-none whitespace-pre-line"
            : "line-clamp-3 whitespace-pre-line"
        }
      >
        {text}
      </p>
      {truncatable && (
        <button
          data-testid={
            expanded ? "video-description-less" : "video-description-more"
          }
          onClick={() => setExpanded(!expanded)}
          className="mt-1 font-medium text-blue-600"
        >
          {expanded ? "lebih sedikit" : "...selengkapnya"}
        </button>
      )}
    </div>
  );
}

Two details do the real work. The measurement runs in useEffect, never during render, because layout does not exist yet on the first pass. And the effect depends on expanded too: once the box is opened, the clamp is gone and the overflow answer is meaningless, so the effect exits early and keeps the last known value.

One consequence is unavoidable on the server. The HTML emitted before hydration contains no toggle at all, because truncatable starts as false and only a real layout can flip it to true. The toggle pops in right after hydration on truncated descriptions. If that flash matters, the alternative is guessing at the server and correcting on the client, which reintroduces the double-render the character-count approach had.

Testing the measurement in jsdom

jsdom has no layout engine. scrollHeight and clientHeight read as 0 on every element, so the real comparison always says "fits" and the toggle never appears. The tests therefore mock both offsets and assert the decision, not the geometry.

import { render, screen } from "@testing-library/react";
import { VideoDescription } from "./VideoDescription";

test("toggle tidak muncul saat teks muat", () => {
  vi.spyOn(HTMLElement.prototype, "scrollHeight", "get").mockReturnValue(0);
  vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(0);

  render(<VideoDescription text="Deskripsi singkat." />);

  expect(
    screen.queryByTestId("video-description-more")
  ).not.toBeInTheDocument();
});

Run the suite with npx vitest run. The first case is the one worth having: equal mocked offsets must leave the toggle out of the DOM entirely. A component that renders the button and hides it with CSS would pass a naive visibility check while still pushing a focusable element into the tab order.

Why the label disappeared

The original block carried a "Deskripsi" label. Inside the new gray rounded box the label is redundant: the box shape is the affordance. Readers recognize the collapsed text by the three-line clamp and the trailing "...selengkapnya" on its own, and the button removes the only thing the label was doing.

To verify the clamp in a browser, shorten the viewport until the text needs four lines and check that the toggle appears; then paste a two-line description into the same page and check that it does not. That second check is the whole point of measuring instead of guessing.

Sources

  • [4] https://developer.mozilla.org/en-US/docs/Web/CSS/line-clamp — line-clamp, CSS, MDN Web Docs
  • [5] https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight — Element scrollHeight, MDN Web Docs
  • [6] https://tailwindcss.com/docs/line-clamp — line-clamp, Tailwind CSS

Related articles