Text

Truncate

Shortens text in the middle so the start and the end stay readable, for paths, hashes, IDs and emails.

Built on
text-overflowFlexboxattr()CSS content alt text
Client JavaScript
None
Works without React
Yes, as HTML and CSS
Source, gzipped
1.5 kB
Dependencies
React
src/components/billing/invoice-table.tsx9f3c2a71be04d5e8c6a1f02b7d9e4c38a5b1f6e0margarethe.lindqvist-okonkwo@example.com
Try its options

Loading its options.

Code
import { Truncate } from "@/components/equip/truncate";

export default function Demo() {
  return (
    <div style={{ display: "grid", gap: 6, width: 200, fontSize: 13 }}>
      <Truncate keep={9}>src/components/billing/invoice-table.tsx</Truncate>
      <Truncate mono keep={6}>9f3c2a71be04d5e8c6a1f02b7d9e4c38a5b1f6e0</Truncate>
      <Truncate keep={14}>margarethe.lindqvist-okonkwo@example.com</Truncate>
    </div>
  );
}
Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:6px;width:200px;font-size:13px">
  <span title="src/components/billing/invoice-table.tsx" class="eq-truncate" data-head="src/components/billing/invoice-" data-tail="table.tsx"><span class="eq-truncate-text">src/components/billing/invoice-table.tsx</span></span>
  <span title="9f3c2a71be04d5e8c6a1f02b7d9e4c38a5b1f6e0" class="eq-truncate" data-mono="true" data-head="9f3c2a71be04d5e8c6a1f02b7d9e4c38a5" data-tail="b1f6e0"><span class="eq-truncate-text">9f3c2a71be04d5e8c6a1f02b7d9e4c38a5b1f6e0</span></span>
  <span title="margarethe.lindqvist-okonkwo@example.com" class="eq-truncate" data-head="margarethe.lindqvist-okonk" data-tail="wo@example.com"><span class="eq-truncate-text">margarethe.lindqvist-okonkwo@example.com</span></span>
</div>

Examples

How much of the end to keep

Pick keep so the part people compare survives: the file name, the last digits, the domain.

keep 4
https://status.example.com/incidents/2024-11-28-eu-west-1-latency
keep 8
https://status.example.com/incidents/2024-11-28-eu-west-1-latency
keep 21
https://status.example.com/incidents/2024-11-28-eu-west-1-latency
Code
import { Truncate } from "@/components/equip/truncate";

const url = "https://status.example.com/incidents/2024-11-28-eu-west-1-latency";

export default function Example() {
  return (
    <dl style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 220px)", gap: "6px 12px", margin: 0, fontSize: 13 }}>
      <dt style={{ color: "var(--eq-ink-2)" }}>keep 4</dt>
      <dd style={{ margin: 0, minWidth: 0 }}>
        <Truncate keep={4}>{url}</Truncate>
      </dd>
      <dt style={{ color: "var(--eq-ink-2)" }}>keep 8</dt>
      <dd style={{ margin: 0, minWidth: 0 }}>
        <Truncate>{url}</Truncate>
      </dd>
      <dt style={{ color: "var(--eq-ink-2)" }}>keep 21</dt>
      <dd style={{ margin: 0, minWidth: 0 }}>
        <Truncate keep={21}>{url}</Truncate>
      </dd>
    </dl>
  );
}
HTMLworks in any framework, with the part's CSS
<dl style="display:grid;grid-template-columns:auto minmax(0, 220px);gap:6px 12px;margin:0;font-size:13px">
  <dt style="color:var(--eq-ink-2)">keep 4</dt>
  <dd style="margin:0;min-width:0">
    <span title="https://status.example.com/incidents/2024-11-28-eu-west-1-latency" class="eq-truncate" data-head="https://status.example.com/incidents/2024-11-28-eu-west-1-lat" data-tail="ency"><span class="eq-truncate-text">https://status.example.com/incidents/2024-11-28-eu-west-1-latency</span></span>
  </dd>
  <dt style="color:var(--eq-ink-2)">keep 8</dt>
  <dd style="margin:0;min-width:0">
    <span title="https://status.example.com/incidents/2024-11-28-eu-west-1-latency" class="eq-truncate" data-head="https://status.example.com/incidents/2024-11-28-eu-west-1" data-tail="-latency"><span class="eq-truncate-text">https://status.example.com/incidents/2024-11-28-eu-west-1-latency</span></span>
  </dd>
  <dt style="color:var(--eq-ink-2)">keep 21</dt>
  <dd style="margin:0;min-width:0">
    <span title="https://status.example.com/incidents/2024-11-28-eu-west-1-latency" class="eq-truncate" data-head="https://status.example.com/incidents/2024-11" data-tail="-28-eu-west-1-latency"><span class="eq-truncate-text">https://status.example.com/incidents/2024-11-28-eu-west-1-latency</span></span>
  </dd>
</dl>

In a narrow table column

With a fixed table layout the cell has a definite width, which is what lets the text shrink inside it.

Bucket objects
KeySize
exports/2024/11/customers-eu-west-1-full.csv.gz48.2 MB
exports/2024/11/customers-us-east-2-full.csv.gz61.7 MB
backups/postgres/primary/base-000000010000004A.tar1.9 GB
Code
import { Truncate } from "@/components/equip/truncate";

const objects = [
  { key: "exports/2024/11/customers-eu-west-1-full.csv.gz", size: "48.2 MB" },
  { key: "exports/2024/11/customers-us-east-2-full.csv.gz", size: "61.7 MB" },
  { key: "backups/postgres/primary/base-000000010000004A.tar", size: "1.9 GB" },
];

const cell = { padding: "6px 8px", borderBottom: "1px solid var(--eq-line)", textAlign: "start" } as const;

export default function Example() {
  return (
    <table style={{ width: "100%", maxWidth: 320, tableLayout: "fixed", borderCollapse: "collapse", fontSize: 13 }}>
      <caption style={{ textAlign: "start", paddingBottom: 6, fontWeight: 650 }}>Bucket objects</caption>
      <colgroup>
        <col />
        <col style={{ width: 76 }} />
      </colgroup>
      <thead>
        <tr>
          <th style={{ ...cell, color: "var(--eq-ink-2)" }}>Key</th>
          <th style={{ ...cell, color: "var(--eq-ink-2)", textAlign: "end" }}>Size</th>
        </tr>
      </thead>
      <tbody>
        {objects.map((o) => (
          <tr key={o.key}>
            <td style={cell}>
              <Truncate mono keep={18}>{o.key}</Truncate>
            </td>
            <td style={{ ...cell, textAlign: "end", fontVariantNumeric: "tabular-nums" }}>{o.size}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}
HTMLworks in any framework, with the part's CSS
<table style="width:100%;max-width:320px;table-layout:fixed;border-collapse:collapse;font-size:13px">
  <caption style="text-align:start;padding-bottom:6px;font-weight:650">Bucket objects</caption>
  <colgroup>
    <col>
    <col style="width:76px">
  </colgroup>
  <thead>
    <tr>
      <th style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:start;color:var(--eq-ink-2)">Key</th>
      <th style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:end;color:var(--eq-ink-2)">Size</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:start">
        <span title="exports/2024/11/customers-eu-west-1-full.csv.gz" class="eq-truncate" data-mono="true" data-head="exports/2024/11/customers-eu-" data-tail="west-1-full.csv.gz"><span class="eq-truncate-text">exports/2024/11/customers-eu-west-1-full.csv.gz</span></span>
      </td>
      <td style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:end;font-variant-numeric:tabular-nums">48.2 MB</td>
    </tr>
    <tr>
      <td style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:start">
        <span title="exports/2024/11/customers-us-east-2-full.csv.gz" class="eq-truncate" data-mono="true" data-head="exports/2024/11/customers-us-" data-tail="east-2-full.csv.gz"><span class="eq-truncate-text">exports/2024/11/customers-us-east-2-full.csv.gz</span></span>
      </td>
      <td style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:end;font-variant-numeric:tabular-nums">61.7 MB</td>
    </tr>
    <tr>
      <td style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:start">
        <span title="backups/postgres/primary/base-000000010000004A.tar" class="eq-truncate" data-mono="true" data-head="backups/postgres/primary/base-00" data-tail="0000010000004A.tar"><span class="eq-truncate-text">backups/postgres/primary/base-000000010000004A.tar</span></span>
      </td>
      <td style="padding:6px 8px;border-bottom:1px solid var(--eq-line);text-align:end;font-variant-numeric:tabular-nums">1.9 GB</td>
    </tr>
  </tbody>
</table>

Drag to resize

Drag the corner of the box: the head gives way first and the last characters never move.

arn:aws:iam::418273650912:role/deploy-pipeline-productionsha256:4f1c8e0b92d7a3e65c08f1b4d29a7e3c6b5f0d81a2e94c7b3f6d1e08a9c5b27fQuarterly board update, final, approved by legal.pdf
Code
import { Truncate } from "@/components/equip/truncate";

export default function Example() {
  return (
    <div
      style={{
        display: "grid",
        gap: 6,
        width: "min(300px, 100%)",
        minWidth: 96,
        maxWidth: "100%",
        padding: 12,
        resize: "horizontal",
        overflow: "hidden",
        border: "1px solid var(--eq-line-strong)",
        borderRadius: "var(--eq-radius-m)",
        fontSize: 13,
      }}
    >
      <Truncate keep={12}>arn:aws:iam::418273650912:role/deploy-pipeline-production</Truncate>
      <Truncate mono keep={6}>sha256:4f1c8e0b92d7a3e65c08f1b4d29a7e3c6b5f0d81a2e94c7b3f6d1e08a9c5b27f</Truncate>
      <Truncate keep={10}>Quarterly board update, final, approved by legal.pdf</Truncate>
    </div>
  );
}
HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:6px;width:min(300px, 100%);min-width:96px;max-width:100%;padding:12px;resize:horizontal;overflow:hidden;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);font-size:13px">
  <span title="arn:aws:iam::418273650912:role/deploy-pipeline-production" class="eq-truncate" data-head="arn:aws:iam::418273650912:role/deploy-pipelin" data-tail="e-production"><span class="eq-truncate-text">arn:aws:iam::418273650912:role/deploy-pipeline-production</span></span>
  <span title="sha256:4f1c8e0b92d7a3e65c08f1b4d29a7e3c6b5f0d81a2e94c7b3f6d1e08a9c5b27f" class="eq-truncate" data-mono="true" data-head="sha256:4f1c8e0b92d7a3e65c08f1b4d29a7e3c6b5f0d81a2e94c7b3f6d1e08a9" data-tail="c5b27f"><span class="eq-truncate-text">sha256:4f1c8e0b92d7a3e65c08f1b4d29a7e3c6b5f0d81a2e94c7b3f6d1e08a9c5b27f</span></span>
  <span title="Quarterly board update, final, approved by legal.pdf" class="eq-truncate" data-head="Quarterly board update, final, approved by" data-tail=" legal.pdf"><span class="eq-truncate-text">Quarterly board update, final, approved by legal.pdf</span></span>
</div>

Props

PropTypeDefaultWhat it does
childrenstringnoneThe full text. It stays whole in the DOM, so copy, find in page and screen readers get all of it.
keepnumber8How many characters at the end always stay visible. When it covers the whole string, the text truncates at the end instead.
monobooleanfalseSets the monospace face, for hashes, IDs and paths.
titlestringchildrenTooltip on hover. Defaults to the full text; pass an empty string to turn it off.
...restOmit<ComponentProps<"span">, "children">noneEvery native span attribute, including id, lang and dir.

Anatomy

root.eq-truncateAn inline flex span. Its ::before draws the head with the ellipsis and its ::after draws the tail.
text.eq-truncate-textThe full string in transparent ink over the visible pieces; this is what is selected, copied and read.

Notes

  • Screen readers read the full string once, from the text element; the visible pieces are generated content with empty alt text, so they are not read.
  • Copying gives the exact full string with no added line break, because the visible head and tail are not DOM text.
  • Selecting part of it by dragging picks characters from the full string at that position, so a drag over the tail selects hidden middle characters; select it all to copy it.
  • The head and tail are pseudo-elements rather than elements because browsers insert a line break between flex items when text is copied.
  • Renders on the server with no client JavaScript; the split is computed once from the string and keep.
  • It needs a width limit from its container, such as a grid track, a fixed table layout or a max-width, to have anything to shrink into.
  • Not a security boundary: the full string is in the DOM and in the data attributes, so do not use it to hide secrets.
  • The title tooltip only appears on hover; for keyboard and touch users who need the full text, show it elsewhere too, such as in a detail view.
  • Meant for one line of text: line breaks inside the string are kept and would break the line.
  • Alt text for generated content (content: attr() / "") is in current Chrome, Edge, Safari 17.4 and Firefox 128. Older engines fall back to plain content, which looks the same but lets some screen readers read the head and tail after the full string.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/truncate.tsx35 lines
import type { ComponentProps } from "react";
import "./truncate.css";

export type TruncateProps = Omit<ComponentProps<"span">, "children"> & {
  /** The full text. Always present in the DOM, so copy and screen readers get all of it. */
  children: string;
  /** How many characters at the end always stay visible. */
  keep?: number;
  /** Sets the monospace face, for hashes, IDs and paths. */
  mono?: boolean;
};

export function Truncate({ children, keep = 8, mono = false, title, className, ...rest }: TruncateProps) {
  // Split by code points so an emoji or accented letter is never cut in half.
  const chars = Array.from(children);
  const wanted = Number.isFinite(keep) ? Math.max(0, Math.floor(keep)) : 8;
  // When the whole string fits in the tail there is nothing to split; it truncates at the end.
  const count = wanted >= chars.length ? 0 : wanted;
  const head = chars.slice(0, chars.length - count).join("");
  const tail = chars.slice(chars.length - count).join("");

  return (
    <span
      {...rest}
      title={title ?? children}
      className={["eq-truncate", className].filter(Boolean).join(" ")}
      data-mono={mono || undefined}
      data-head={head || undefined}
      data-tail={tail || undefined}
      data-short={(head !== "" && chars.length - count < 3) || undefined}
    >
      <span className="eq-truncate-text">{children}</span>
    </span>
  );
}
components/equip/truncate.css62 lines
@layer equip {
  /*
   * The visible text is drawn by ::before (the head) and ::after (the tail) from data
   * attributes. Pseudo-elements are not part of the DOM, so they are never copied, and the
   * empty alt text after the slash keeps them out of the accessibility tree. The real string
   * sits in .eq-truncate-text, laid over them in transparent ink, where selection, copy and
   * screen readers find it once and in one piece. Real head and tail elements would be flex
   * items, and browsers put a line break between flex items when text is copied.
   */
  .eq-truncate {
    position: relative;
    display: inline-flex;
    max-width: 100%;
    min-width: 0;
    vertical-align: bottom;
  }

  .eq-truncate[data-mono] {
    font-family: var(--eq-font-mono);
    font-variation-settings: var(--eq-font-mono-settings);
  }

  .eq-truncate[data-head]::before {
    /* The first declaration is the fallback for engines without alt text for content. */
    content: attr(data-head);
    content: attr(data-head) / "";
    flex: 0 1 auto;
    /* Keeps at least a character and the ellipsis in view. */
    min-width: 2ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: pre;
  }

  /* A head of one or two characters is not worth an ellipsis; it never shrinks. */
  .eq-truncate[data-short]::before {
    min-width: max-content;
  }

  .eq-truncate[data-tail]::after {
    content: attr(data-tail);
    content: attr(data-tail) / "";
    flex: none;
    white-space: pre;
  }

  .eq-truncate-text {
    position: absolute;
    inset: 0;
    overflow: hidden;
    white-space: pre;
    color: transparent;
    /* Forced colours would otherwise paint it over the visible pieces. */
    forced-color-adjust: none;
  }

  /* A see-through highlight, so the visible pieces still show under a selection. */
  .eq-truncate-text::selection {
    color: transparent;
    background: color-mix(in srgb, var(--eq-focus) 26%, transparent);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index