You are here

Truncated expanding text field with "show more" link in Drupal 8 & Twig

I've been working on a website with the requirement to truncate certain text fields—display the first several words of the text, with a "show more" link which on click, expands the rest of the text.

As I was under a very tight deadline, I found and implemented a jQuery plugin that did what I needed. Quick and easy.

However, that approach creates several problems. One, it's a lot of code for a pretty simple function. Two, since it's applied client-side, not server-side, it didn't work on first page hit. People arriving at the page would see the un-truncated summaries, and only on refresh would the "show more" functionality be added. Thirdly, this particular website is multi-language, and the "show more" text added by the plugin wasn't being translated.

So I set out to do it properly with Drupal 8. At first I searched for a module, but there didn't seem to be any, and that's also a heavy solution for a simple function.

So I decided to try to do it in Twig. I found a few snippets of code that only partly did what I needed. A simple truncated text field is a pretty common requirement, but it seems fewer people want to split text in two and hide/display part of it. Attempting to modify the snippets led to no success and a lot of frustration, so I realized I'd need to dig into the Twig documentation to figure it out.

It turns out it's actually very easily done with Twig filters and a bit of JavaScript.

In this case, we're working with a field called "synopsis" and we're in a node template (e.g. node--content-type.html.twig). If it were, for example, a Views template, we'd access the field with fields.field_synopsis.content.

First, we need to filter the text field output in Twig. We set a variable which is equal to the field content, with several filters:

{% set synopsis = content.field_synopsis|render|striptags|trim|split(' ') %}

  • First, the render filter converts the field content from an array into a string, allowing the other filters to be applied.
  • "Striptags" removes any HTML from the field content, as it can cause problems if tags aren't formatted properly or get cut.
  • "Trim" removes any extra whitespace at the beginning and end of the field. This is necessary for the next filter to work properly.
  • "Split" is the really crucial filter. The field content created by "render" is an undifferentiated string of characters. Split breaks it into chunks based on the delimitor character that you pass it, in this case, a space. The filter looks for the delimitor in the content and breaks it into separate strings with each new string beginning after each delimitor. Delimiting by spaces, then, effectively breaks the output into words.

Now that we have our "synopsis" variable with the correctly formatted field content, we can use it in our Twig template. We need to display synopsis in two different chunks: first, however many words we want in our teaser (which will display on page load) and second, the remainder of the text, which will display when the user clicks "show more". We also need to add an ellipsis (…) at the end of the first chunk of text, and the element to click to show the rest.

We want to wrap this all in an if statement which will only do this processing if our field is more than 50 words. (Since we've divided the field into words, the length is counted by words rather than characters.) If it's shorter, the whole field will display normally:

{% if synopsis|length > 50 %}
  {# the rest of our code will go here #}
{% else %}
  {{ content.field_synopsis }}
{% endif %}

Everything we do from this point on will be between the {% if %} and {% else %} tags; you'll find it all together further on.

First, we're going to create a variable for the text teaser, with an ellipsis character appended wrapped in a span element for easy hiding later:

{% set teaser = synopsis|slice(0, 40)|join(' ') ~ '<span class="summary-ellipsis">&hellip;</span>' %}

This variable takes our previously-created synopsis variable and applies two more filters: slice, which creates a "slice" or chunk of the synopsis output. The arguments in parentheses are two: the starting point, in this case 0 or the first word; the second, the endpoint, in this case 40. I chose 40 because I wanted at least 10 words of difference between the teaser and full text, as it doesn't make sense for users to click "show more" for only one or two words.

Secondly, the "join" filter takes this slice of 40 separate strings, and puts them back together as one string with the delimitor character that you choose (in this case, a space) between them. This is essential for printing the output; otherwise, you could only print out individual words like so: {{ summary[0] }}.

Thirdly, we append the string which contains the ellipsis and its wrapping span.

We then create a variable for the remainder text:

{% set remainder = synopsis|slice(40)|join(' ') %}

The slice filter in this case starts at word 40, where the teaser slice left off. I'm not sure why it's 40 and not 41, but if you set it to 41, it will skip a word.

Since we've passed no second argument, the slice will go to the end of the output, i.e., the last word.

We then print the teaser in our template:

{{ teaser|raw }}

The "raw" filter is needed because otherwise the HTML in our ellipsis element will be output as characters rather than encoded HTML.

Next, we add the button which users will click to show the full summary. For accessibility purposes, we use a button rather than a link because it's performing an action, rather than navigating to a different page.

<button aria-expanded="false" class="show-more">{% trans %}(Show full summary){% endtrans %}</button>

The aria-expanded attribute indicates to a screen reader user that something will be revealed on pressing the button. We wrap the button text in {% trans %} tags so it can be translated through Drupal's interface.

We then print the remainder text, wrapped in a span so we can target it to hide/show. We also put the "hidden" attribute on it so the text does not display:

<span class="summary-remainder" hidden>{{ remainder|raw }}</span>

The full output, wrapped in a div with a class so we can easily target it with JavaScript:

<div class="text-show-more">
{% set synopsis = fields.field_synopsis.content|render|striptags|trim|split(' ') %}

{% if synopsis|length > 30 %}

  {% set teaser = synopsis|slice(0, 20)|join(' ') ~ '<span class="summary-ellipsis">&hellip;</span>' %}

  {% set remainder = synopsis|slice(20)|join(' ') %}

  <p>
{{ teaser|raw }}
<button aria-expanded="false" class="show-more">{% trans %}(Show full summary){% endtrans %}</button>
<span class="summary-remainder" hidden>{{ remainder|raw }}</span>
  </p>
{% else %}
  {{ fields.field_synopsis.content }}
{% endif %}
</div>

Css

My use-case required minimal CSS, only to make the button styled like a regular text link. Your use-case may vary; I'm not going to go into CSS here.

JavaScript

The JS required to make this work is minimal. First we get every div with the class "text-show-more", we add a "click" event listener to it, and if the click is on the button we remove the button and ellipsis from display, and remove the "hidden" attribute from the remainder text so it is shown.

(function showMore() {
  var text = document.querySelectorAll(".text-show-more");
    for (var i = 0; i < text.length; i++) {
      text[i].addEventListener("click", function(event) {
        var button = this.querySelector("button.show-more");
        var ellipsis = this.querySelector(".summary-ellipsis");
        var remainder = this.querySelector(".summary-remainder");
        <code>
        if (event.target === button) {
          button.style.display = "none";
          ellipsis.style.display = "none";
          remainder.removeAttribute("hidden");
        }
      });
    }
})();

I'm not the world's best JavaScript programmer, so if anyone has suggestions to make this work better I welcome them!

"Show less" link

What if you want a "show less" button to display and collapse the remainder text again so only the teaser shows? That's a common requirement.

In that case, you could add a "show less" button at the end of the remainder text, and toggle its display on the click of the "show more", and vice-versa. Or you could toggle the innerHTML of the show more button and its function based on its display state. There are a few different ways to accomplish this and if anyone has questions about how to do it let me know in the comments!