Cross-browser multi-line text overflow with ellipsis appended within a fixed width and height

Viewed 115591

I made an image for this question to make it easier to understand.

Is it possible to create an ellipsis on a <div> with a fixed width and multiple lines?

text-overflow

I’ve tried some jQuery plugins out here and there, but cannot find the one I’m looking for. Any recommendation? Ideas?

26 Answers

You can use -webkit-line-clamp property with div.

-webkit-line-clamp: <integer> which means set the maximum number of lines before truncating the content and then displays an ellipsis (…) at the end of the last line.

div {
  width: 205px;
  height: 40px;
  background-color: gainsboro;
  overflow: hidden;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  
  /* <integer> values */
  -webkit-line-clamp: 2;
}
<div>This is a multi-lines text block, some lines inside the div, while some outside</div>

Found this short CSS-only solution in Adrien Be's answer:

.line-clamp {
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical; 
  overflow: hidden; 
}

As of March 2020 browser support is 95.3%, not being supported in IE and Opera Mini. Works on Chrome, Safari, Firefox and Edge.

Very simple javascript solution. Divs has to be styled f.e.:

.croppedTexts { 
  max-height: 32px;
  overflow: hidden;
}

And JS:

var list = document.body.getElementsByClassName("croppedTexts");
for (var i = 0; i < list.length; i++) {
  cropTextToFit(list[i]);
}

function cropTextToFit (o) {
  var lastIndex;
  var txt = o.innerHTML;
  if (!o.title) o.title = txt;

  while (o.scrollHeight > o.clientHeight) {
    lastIndex = txt.lastIndexOf(" ");
    if (lastIndex == -1) return;
    txt = txt.substring(0, lastIndex);
    o.innerHTML = txt + "…";
  }
}

Maybe quite late but using SCSS you can declare a function like:

@mixin clamp-text($lines, $line-height) {
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: $lines;
  line-height: $line-height;
  max-height: unquote('#{$line-height*$lines}em');

  @-moz-document url-prefix() {
    position: relative;
    height: unquote('#{$line-height*$lines}em');

    &::after {
      content: '';
      text-align: right;
      position: absolute;
      bottom: 0;
      right: 0;
      width: 30%;
      height: unquote('#{$line-height}em');
      background: linear-gradient(
        to right,
        rgba(255, 255, 255, 0),
        rgba(255, 255, 255, 1) 50%
      );
    }
  }
}

And use it like:

.foo {
    @include clamp-text(1, 1.4);
}

Which will truncate the text to one line and knowing that it is 1.4 its line-height. The output expected is chrome to render with ... at the end and FF with some cool fade at the end

Firefox

enter image description here

Chrome

enter image description here

Here I made another library with faster algorithm. Please check:

https://github.com/i-ahmed-biz/fast-ellipsis

To install using bower:

bower install fast-ellipsis

To install using npm:

npm install fast-ellipsis 

Hope you enjoy!

I have made a version that leaves the html intact. jsfiddle example

jQuery

function shorten_text_to_parent_size(text_elem) {
  textContainerHeight = text_elem.parent().height();


  while (text_elem.outerHeight(true) > textContainerHeight) {
    text_elem.html(function (index, text) {
      return text.replace(/(?!(<[^>]*>))\W*\s(\S)*$/, '...');
    });

  }
}

$('.ellipsis_multiline').each(function () {
  shorten_text_to_parent_size($(this))
});

CSS

.ellipsis_multiline_box {
  position: relative;
  overflow-y: hidden;
  text-overflow: ellipsis;
}

jsfiddle example

I wrote an angular component that solves the problem. It splits a given text into span elements. After rendering, it removes all overflowing elements and places the ellipsis right after the last visible element.

Usage example:

<app-text-overflow-ellipsis [text]="someText" style="max-height: 50px"></app-text-overflow-ellipsis>

Stackblitz demo: https://stackblitz.com/edit/angular-wfdqtd

The component:

import {
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  ElementRef, HostListener,
  Input,
  OnChanges,
  ViewChild
} from '@angular/core';

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'app-text-overflow-ellipsis',
  template: `
    <span *ngFor="let word of words; let i = index" [innerHTML]="word + (!endsWithHyphen(i) ? ' ' : '')"></span>
    <span #ellipsis [hidden]="!showEllipsis && !initializing" [class.initializing]="initializing" [innerHTML]="'...' + (initializing ? '&nbsp;' : '')"></span>
  `,
  styles: [`
    :host {
      display: block; 
      position: relative;
    }
    .initializing {
      opacity: 0;
    }
  `
  ]
})

export class TextOverflowEllipsisComponent implements OnChanges {
  @Input()
  text: string;

  showEllipsis: boolean;
  initializing: boolean;

  words: string[];

  @ViewChild('ellipsis')
  ellipsisElement: ElementRef;

  constructor(private element: ElementRef, private cdRef: ChangeDetectorRef) {}

  ngOnChanges(){
    this.init();
  }

  @HostListener('window:resize')
  init(){
    // add space after hyphens
    let text = this.text.replace(/-/g, '- ') ;

    this.words = text.split(' ');
    this.initializing = true;
    this.showEllipsis = false;
    this.cdRef.detectChanges();

    setTimeout(() => {
      this.initializing = false;
      let containerElement = this.element.nativeElement;
      let containerWidth = containerElement.clientWidth;
      let wordElements = (<HTMLElement[]>Array.from(containerElement.childNodes)).filter((element) =>
        element.getBoundingClientRect && element !== this.ellipsisElement.nativeElement
      );
      let lines = this.getLines(wordElements, containerWidth);
      let indexOfLastLine = lines.length - 1;
      let lineHeight = this.deductLineHeight(lines);
      if (!lineHeight) {
        return;
      }
      let indexOfLastVisibleLine = Math.floor(containerElement.clientHeight / lineHeight) - 1;

      if (indexOfLastVisibleLine < indexOfLastLine) {

        // remove overflowing lines
        for (let i = indexOfLastLine; i > indexOfLastVisibleLine; i--) {
          for (let j = 0; j < lines[i].length; j++) {
            this.words.splice(-1, 1);
          }
        }

        // make ellipsis fit into last visible line
        let lastVisibleLine = lines[indexOfLastVisibleLine];
        let indexOfLastWord = lastVisibleLine.length - 1;
        let lastVisibleLineWidth = lastVisibleLine.map(
          (element) => element.getBoundingClientRect().width
        ).reduce(
          (width, sum) => width + sum, 0
        );
        let ellipsisWidth = this.ellipsisElement.nativeElement.getBoundingClientRect().width;
        for (let i = indexOfLastWord; lastVisibleLineWidth + ellipsisWidth >= containerWidth; i--) {
          let wordWidth = lastVisibleLine[i].getBoundingClientRect().width;
          lastVisibleLineWidth -= wordWidth;
          this.words.splice(-1, 1);
        }


        this.showEllipsis = true;
      }
      this.cdRef.detectChanges();

      // delay is to prevent from font loading issues
    }, 1000);

  }

  deductLineHeight(lines: HTMLElement[][]): number {
    try {
      let rect0 = lines[0][0].getBoundingClientRect();
      let y0 = rect0['y'] || rect0['top'] || 0;
      let rect1 = lines[1][0].getBoundingClientRect();
      let y1 = rect1['y'] || rect1['top'] || 0;
      let lineHeight = y1 - y0;
      if (lineHeight > 0){
        return lineHeight;
      }
    } catch (e) {}

    return null;
  }

  getLines(nodes: HTMLElement[], clientWidth: number): HTMLElement[][] {
    let lines = [];
    let currentLine = [];
    let currentLineWidth = 0;

    nodes.forEach((node) => {
      if (!node.getBoundingClientRect){
        return;
      }

      let nodeWidth = node.getBoundingClientRect().width;
      if (currentLineWidth + nodeWidth > clientWidth){
        lines.push(currentLine);
        currentLine = [];
        currentLineWidth = 0;
      }
      currentLine.push(node);
      currentLineWidth += nodeWidth;
    });
    lines.push(currentLine);

    return lines;
  }

  endsWithHyphen(index: number): boolean {
    let length = this.words[index].length;
    return this.words[index][length - 1] === '-' && this.words[index + 1] && this.words[index + 1][0];
  }
}

CSS line clamping is the simplest solution to the problem of multiline truncation.

Line clamping is supported by all major browsers, however it is slightly broken in Webkit. The bottom edge of the box should always be aligned with the bottom of the last line of text. Webkit, when eliding, instead aligns the bottom of the box with the elided line's "text bottom", not its real bottom. This discrepancy becomes very noticeable for large line-height values.

Run the following snippet in Safari to see the problem.

div {
    border: 10px solid black;
    line-height: 2;

    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}
<div>Vel mollis dignissim duis ligula nisl tincidunt eget aliquet eget efficitur quis justo integer pellentesque lacus. Viverra libero viverra finibus. Non id diam lorem ipsum dolor sit amet. Consectetur adipiscing elit sed ac eleifend lorem et placerat. Ante vestibulum. Congue augue vel turpis aliquet. Urna a varius urna convallis sed integer dapibus lobortis enim non venenatis orci varius ut fusce porttitor eros vel. Mollis dignissim duis ligula nisl tincidunt eget aliquet eget efficitur.</div>

Fortunately, the element's height can easily be corrected with a bit of JavaScript.

if (navigator.vendor === "Apple Computer, Inc.") {
    const {lineHeight} = window.getComputedStyle(element);
    if (lineHeight.endsWith("px")) {
        const line_height_px = parseFloat(lineHeight);
        element.style.height = line_height_px * Math.ceil(
            element.clientHeight / line_height_px
        ) + "px";
     }
}
Related