Repository: pydata/xarray (Python)

Issue:
html repr of xarray object (for the notebook)
Edit: preview for `Dataset` and `DataArray` (pure html/css)

`Dataset`: https://jsfiddle.net/tay08cn9/4/
`DataArray`: https://jsfiddle.net/43z4v2wt/9/

---

I started to think a bit more deeply about how could look like a more rich, html-based representation of xarray objects that we would see, e.g., in jupyter notebooks.

Here are some ideas for `Dataset`: https://jsfiddle.net/9ab4c3tr/35/

Some notes:
- The html repr looks pretty similar than the plain-text repr. I think it's better if they don't differ too much from each other.
- For the sake of consistency, I've stolen some style from `pandas.Dataframe` repr as it is shown in jupyterlab.
- I tried to emphasize the most important parts of the repr, i.e., the lists of dimensions, coordinates and variables.
- I think it's best if we keep a very lightweight implementation, i.e., pure HTML/CSS (no Javascript). It already allows some interaction like hover effects and collapsible sections. However, I doubt that more fancy stuff (like, e.g., highlighting on hover a specific dimension simultaneously at several places of the repr) would be possible here without Javascript. I have limited skills in this area, though.

It is still, of course, some preliminary thoughts. Any feedback/suggestion is welcome, even opinions about whether an html repr is really needed or not!



Discussion:
OMG this is so cool!
OMG indeed this looks fantastic! We should test this on more examples, but I already love it. The underline is a great way to illustrate which variables are dimensions.

> I think it's best if we keep a very lightweight implementation, i.e., pure HTML/CSS (no Javascript)

Agreed. Notebook viewers tend to sanitize out JavaScript, especially if originating from an unknown source.

> highlighting on hover a specific dimension simultaneously at several places of the repr

This would also be awesome, if possible with pure CSS. I don't know, but I'm going to bug my colleague who does JavaScript visualization.
OK, so bad news is that it [not possible](https://stackoverflow.com/questions/19480385/css-hover-can-it-effect-multiple-divs-with-same-class-name) to select elements other than siblings or descendant tags with CSS. So selecting "cousin" tags like a dimension name at multiple locations in the repr is out.

But we could potentially add JavaScript for fancy hover effects. Even if it gets stripped out in many cases (for untrusted notebooks), it should degrade gracefully to the HTML only repr. My main concern would annoying prompts in the notebook interface that ask a user if they want to trust outputs or not.
Wow, great job @benbovy!

With the upcoming move towards Jupyter Lab and a better infrastructure for custom plugins, could this serve as the basis for a "NetCDF Extension" for Jupyter Lab? It would be great if double clicking on a NetCDF file in the JLab file explorer could open up this sort of information, or even a quick and dirty ncview-like plotter.
Thanks for the feedback!

Here are a few more ideas: https://jsfiddle.net/9ab4c3tr/48/

- main section titles are colored so that these are more detached from the content (not sure I really like it, though),
- subtle shade variations, notably for displaying the first values for each variable,
- drop-downs for displaying attributes per variable if any (collapsed by default).
- hover-effect for dimensions: highlight all variables having the hovered dimension (uses Javascript)

We might use drop-downs to display other useful information as well, such as the type of array (e.g., dask-array, in-memory numpy.array, etc...)

Fancy hover effects would be awesome indeed, although my concern is that too much hover effect would be a source of distraction.

A jupyterlab NetCDF viewer extension would be awesome too! It might also leverage phosphor's datagrid (https://github.com/phosphorjs/phosphor/issues/285) to explore the raw data values.

Although this could clearly be made more fancy and complicated, I think
what you have here would already be a great addition. I love the drop-down
attributes (those are missing from the standard repr).

Don't hesitate to start a PR! More fanciness could always be added in the
future based on user feedback.

On Thu, Oct 12, 2017 at 9:01 AM, Benoit Bovy <notifications@github.com>
wrote:

> Thanks for the feedback!
>
> Here is a few more ideas: https://jsfiddle.net/9ab4c3tr/48/
>
>    - main sections titles are colored so that these are more detached
>    from the content (not sure I really like it, though),
>    - subtle shade variations, notably for displaying the first values for
>    each variable,
>    - drop-downs for displaying attributes per variable if any (collapsed
>    by default).
>    - hover-effect for dimensions: highlight all variables having the
>    hovered dimension (uses Javascript)
>
> We might use drop-downs to display other useful information as well, such
> as the type of array (e.g., dask-array, in-memory numpy.array, etc...)
>
> Fancy hover effects would be awesome indeed, although my concern is that
> too much hover effect would be a source of distraction.
>
> A jupyterlab NetCDF viewer extension would be awesome too! It might also
> leverage phosphor's datagrid (phosphorjs/phosphor#285
> <https://github.com/phosphorjs/phosphor/issues/285>) to explore the raw
> data values.
>
> —
> You are receiving this because you commented.
> Reply to this email directly, view it on GitHub
> <https://github.com/pydata/xarray/issues/1627#issuecomment-336127376>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/ABJFJrhyjDbEVEz5AqUN6l2ppExwE3xiks5srg2MgaJpZM4P2LOr>
> .
>

As long as we're nit-picking style....

> main section titles are colored so that these are more detached from the content (not sure I really like it, though),

I slightly prefer the grayscale you had before -- the section titles are already well detached.

> subtle shade variations, notably for displaying the first values for each variable,

Love it!

> drop-downs for displaying attributes per variable if any (collapsed by default).

It is wonderful to expose this information! I have a slight concern that the `+` icons could be confusing with the `*` we use for index variables in the normal repr. I don't have any good ideas for fixing this yet.

> hover-effect for dimensions: highlight all variables having the hovered dimension (uses Javascript)

For highlighting, maybe something a little more subtle would work well, either:
1. only highlighting the dimension names below, not the full variables, or
2. only highlight full variables with a matching name, as well as all matching dimensions.
Before starting a PR on this, I'd like to get the design right (at least the static part) and also to clarify the repr of other xarray objects such as `DataArray`, `Variable`, `Dataset.coords` and `Dataset.data_vars`.

For `Dataset.coords` and `Dataset.data_vars` it would be pretty straightforward (we may simply display the corresponding sections in `Dataset` html repr).

For `DataArray` and `Variable` I have still no clear idea on what would be a good representation for the data values:

- Option A: embed the plain-text representation of the wrapped `numpy.array` (or `dask.array`) in a HTML container (https://jsfiddle.net/43z4v2wt/1/). The result is not that bad, although monospace and sans-serif fonts do not mix very well IMO.

- Option B: a html-formatted table for data values as well. That would be tricky for >2d arrays, it has already been discussed in other issues without real consensus. 
>  have a slight concern that the + icons could be confusing with the * we use for index variables in the normal repr.

Maybe putting the `+` icon more to the left would do the trick (https://jsfiddle.net/9ab4c3tr/49/).

> For highlighting, maybe something a little more subtle would work well

Changing font-weight instead of background-color works better IMO (same link above).

> Option A: embed the plain-text representation of the wrapped numpy.array (or dask.array) in a HTML container (https://jsfiddle.net/43z4v2wt/1/). The result is not that bad, although monospace and sans-serif fonts do not mix very well IMO.

I think this is probably our best option. My main suggestion is it should be possible to click somewhere (maybe on top `xarray.DataArray` header) to hide/show the data section.
> Maybe putting the + icon more to the left would do the trick (https://jsfiddle.net/9ab4c3tr/49/).

Now it just looks a little out of place :). I liked it better closer to the variable name.

> Changing font-weight instead of background-color works better IMO (same link above).

Hmm, I'm not sure. I find text that moves very distracting -- I like changing background colors.
> My main suggestion is it should be possible to click somewhere (maybe on top xarray.DataArray header) to hide/show the data section.

See https://jsfiddle.net/43z4v2wt/3/

> I find text that moves very distracting

Indeed, I agree now, it's annoying.
One more trick we could add for the Dataset repr:

- When hovering over data, show repr for the full array values (as a numpy array) in a box (similar to "title" text, but ideally fixed-width format).
> When hovering over data, show repr for the full array values (as a numpy array) in a box (similar to "title" text, but ideally fixed-width format).

That would be nice, although I guess that it would require javascript.

Alternatively, we can imagine two drop-downs per variable, one for the attributes and one for the full array values (numpy or dask repr). Each would be shown/hidden by two distinct symbols or links, perhaps both located on the right after the preview-values so that we avoid confusion with symbol `*` in the normal repr.

In the `DataArray` repr,  instead of completely collapsing the data section we may reduce it into a one-line preview of the first values. We can even imagine showing the reduced version by default in cases where the full data section would take too much vertical space.
A comment regarding the use of javascript: after reading this discussion (https://github.com/bokeh/bokeh/issues/6700) I'm not sure that maintaining a javascript-based repr that works with both jupyterlab and the classic notebook is worth the few fancy features it would provide.

I'd rather stick with a pure html/css solution here. It might still co-exist with a full-featured jupyterlab extension for viewing NetCDF files.
> Alternatively, we can imagine two drop-downs per variable, one for the attributes and one for the full array values (numpy or dask repr). Each would be shown/hidden by two distinct symbols or links, perhaps both located on the right after the preview-values so that we avoid confusion with symbol * in the normal repr.

Yes, I like that. Possibly clicking on the array values would show the larger preview.

I did play around a little with a hover drop down, but the positioning is a little hacky:
https://jsfiddle.net/gux879hn/2/
Note that the `info()` method was added a while ago: https://github.com/pydata/xarray/pull/1176

At least in the notebook, this change would make it obsolete.
New version for `Dataset`: https://jsfiddle.net/9ab4c3tr/50/

- clicking on the name of a variable with `A` symbol shows its attributes.
- clicking on the first-values of a variable shows the `.data` repr  
And for `DataArray`: https://jsfiddle.net/43z4v2wt/5/   (possibility to reduce the full data repr to a one-line preview)


This is wonderful!

Is it reasonable to add a drop down in the attribute section if there are too many items there?

My data sometimes have a long list of attributes and important information about coordinate and data_vars are not seen without scrolling back the page.
(This may be a rare case though...)

> Is it reasonable to add a drop down in the attribute section if there are too many items there?

In the examples above, you can click on the `Attributes` section title to collapse the whole section (same with coordinates and data variables sections).

Given your case, maybe it would be nice to add a rule to show the attribute section collapsed when the number of attributes is too long. If we allow that, then maybe it would be nice to also show the number of items in the section titles, e.g., `► Attributes (10):`


I have a slight proposed tweak on the `Dataset` repr (https://jsfiddle.net/jrot9pex/1/). It makes two changes:
- Changes the letter "A" to "a" for attributes. Maybe this is just me, but the capital "A" feels very loud, and reminds me of [the scarlet letter](https://www.google.com/search?q=scarlet+letter+book)!
- Moves the marker "a" closer to the variable name, which makes it slightly clearer that it's associated. 

> Note that the info() method was added a while ago: #1176
>
> At least in the notebook, this change would make it obsolete.

Yes, but let's keep it -- it have a nice pure text format (from ncdump) which works especially well with copy & paste.

> Given your case, maybe it would be nice to add a rule to show the attribute section collapsed when the number of attributes is too long. If we allow that, then maybe it would be nice to also show the number of items in the section titles, e.g., `► Attributes (10):`

Yes, I like this general idea -- though we might only show the number when it is collapsed, e.g., `► Attributes: (10)`
> If we allow that, then maybe it would be nice to also show the number of items in the section titles, e.g., ► Attributes (10):

I like this idea. Looking forward to having it.
> though we might only show the number when it is collapsed

Good idea!

> Changes the letter "A" to "a" for attributes

Agreed.

> Moves the marker "a" closer to the variable name

The good (or bad?) thing with the marker "a" to the very left is that it is aligned with the drop-down symbols of the main sections, but to me either way is fine.

Let's summarize all suggestions so far:

Dataset: https://jsfiddle.net/tay08cn9/2/
DataArray: https://jsfiddle.net/43z4v2wt/7/

I think that we're getting close to something good!

I'll wait a bit before starting a PR (maybe sometime next week), in case other feedbacks or suggestions come up.


>OMG this is so cool!

ditto wow I can't wait for this to be in!
@benbovy it seems like that discussion has stalled out... are you ready to put together that PR? :)

Personally, I'm very happy with your current version. You might even convince me to hold off on the v0.10 release to include it!
Sorry for my late reply @shoyer !

Yes I'm happy with this version too, I'll open asap a PR! Unfortunately I hadn't much time to work on this these last two weeks, but I want to put this in my priorities next week.

I guess that it is a bit late for v0.10 release which is already on track? I still don't exactly know how much time I'll need to implement this. I haven't thought yet about all implementation details (e.g., how to calculate the width of the variable name column? potential issues with jupyter notebook / jupyterlab...). It might also require some refactoring and/or new public API (e.g., `to_html` like in pandas).
  
> I guess that it is a bit late for v0.10 release which is already on track?

Yes, probably at this point. But hopefully we can do a shorter release cycle for v0.11. Also, though this *is* a big visual change, I'm not sure it's actually a breaking change, per se. Only notebook output will change, not programmatic use of `repr()`.
> Also, though this is a big visual change, I'm not sure it's actually a breaking change, per se.

Yes, but once it's there I'm sure that we'll get bug reports right away if it doesn't work well, because this is a very visual change. Therefore I am almost in favor of pushing this forward as fast as possible and see how it goes, maybe followed by a quick 0.10.1

But since I'm not the one doing the job I'm not allowed to say anything more here ;)
I started working on a new PR (not yet submitted), but before continuing the work I really need to know how best we can include the CSS code in rich outputs for the notebook (e.g., inline CSS or using the `<style>` tag) without requiring any extension for the various front-end applications.

It is not really clear to me if it is even supported so I opened an issue on the jupyterlab side (see reference above).
So we have different options regarding the tools to use for implementing these rich representations:

- Using [vdom](https://github.com/nteract/vdom). This option is very pythonic and is suggested by jupyterlab and nteract developers. I have used `vdom` to implement the rich repr of `Dataset` (not fully working yet + you need last jupyterlab or nteract to see it): https://gist.github.com/benbovy/a30f286f7fdf9528c4d0c7980be9b6a7. `vdom` is still in development, though. It is not yet supported by all front-ends. Currently it is supported in jupyterlab and nteract and support will be added soon for the classic notebook (and nbviewer?)

- Using a template system like `jinja2`. It doesn't require any specific support on the various notebook front-ends, but it still adds a dependency.

- Using Python strings formatting. No dependencies, but more tough to maintain.

I'd like to know what are your thoughts. Should we go with `vdom` and wait a bit until the tool is more mature / more front-ends are supported (hopefully soon, which let us some time if we can wait for release v0.11)? Or do you want it right now and so it's perhaps better to use templates / formatting?

 
I am OK adding new (optional) Python dependencies like vdom or Jinja2 if that makes implementing and maintaining this easier.

If we can solve the problem of generating HTML from vdom as a fallback (https://github.com/nteract/vdom/issues/43) and the vdom developers are supportive for various issues that come up, that could easily be the best option.

I would be reluctant to *only* support vdom output, because there are a lot of legacy notebook viewing interfaces (including various IDEs, cloud hosted notebook environments and rendering on GitHub) that could take a while (years?) to support it.
The other thing to watch out for is if the vdom Python API is still immature and likely to lead to additional works when it changes in the future. This is somewhat of a judgment call. My sense is that they are taking a careful design to the project but it is still in the early days so it's hard to say for sure.
Yes good points.

One part of me (the one that loves every cool, new package) says "let's use `vdom` for this right now", and the other (more wise) part says "let's use Python string formatting  -- not even sure we need something like `jinja2` --, we'll switch to `vdom` once it is more mature and it will be quite easy to do so."

@benbovy - this came up in conversation today with @shoyer and a number of Jupyter devs. What is the current interest on the subject?  
Yeah I really need to continue the work in #1820, this PR has stalled for too long!

Last time I worked on this I was struggling a bit on good column auto-sizing and alignment with a pure CSS implementation (i.e., using CSS grid, `display: content`), but that's not really a blocker I think.

Another option would be to write a jupyterlab mime render extension. This narrows the supported front-ends, but I guess the issue of front-end theme integration will be easier to solve. Also, it will be possible to add more fancy features later.

Did you were at SciPy? I hope you had a great time! I wanted to attend the conference this year but finally I couldn't make it, unfortunately.
> Last time I worked on this I was struggling a bit on good column auto-sizing and alignment with a pure CSS implementation...but that's not really a blocker I think.

Agreed. I think that is something we can work on over time. A jupyterlab extension would be cool too but, as you say, it would have a smaller footprint in the shor-term.

Also, yeah, Scipy was great. I think I'll go back. 

Thought I'd bump this (hopefully no one minds).   I think that this is great!
🎉 🍰 🍾 🏆 🏅



Patch:
diff --git a/MANIFEST.in b/MANIFEST.in
index a006660e5fb..4d5c34f622c 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -6,3 +6,4 @@ prune doc/generated
 global-exclude .DS_Store
 include versioneer.py
 include xarray/_version.py
+recursive-include xarray/static *
diff --git a/doc/whats-new.rst b/doc/whats-new.rst
index 9d3e64badb8..12bed8f332e 100644
--- a/doc/whats-new.rst
+++ b/doc/whats-new.rst
@@ -36,6 +36,12 @@ New Features
     ``pip install git+https://github.com/andrewgsavage/pint.git@refs/pull/6/head)``.
     Even with it, interaction with non-numpy array libraries, e.g. dask or sparse, is broken.
 
+- Added new :py:meth:`Dataset._repr_html_` and :py:meth:`DataArray._repr_html_` to improve
+  representation of objects in jupyter. By default this feature is turned off
+  for now. Enable it with :py:meth:`xarray.set_options(display_style="html")`.
+  (:pull:`3425`) by `Benoit Bovy <https://github.com/benbovy>`_ and
+  `Julia Signell <https://github.com/jsignell>`_.
+
 Bug fixes
 ~~~~~~~~~
 - Fix regression introduced in v0.14.0 that would cause a crash if dask is installed
diff --git a/setup.py b/setup.py
old mode 100644
new mode 100755
index 08d4f54764f..cba0c74aa3a
--- a/setup.py
+++ b/setup.py
@@ -104,5 +104,7 @@
     tests_require=TESTS_REQUIRE,
     url=URL,
     packages=find_packages(),
-    package_data={"xarray": ["py.typed", "tests/data/*"]},
+    package_data={
+        "xarray": ["py.typed", "tests/data/*", "static/css/*", "static/html/*"]
+    },
 )
diff --git a/xarray/core/common.py b/xarray/core/common.py
index 45d860a1797..1a8cf34ed39 100644
--- a/xarray/core/common.py
+++ b/xarray/core/common.py
@@ -1,5 +1,6 @@
 import warnings
 from contextlib import suppress
+from html import escape
 from textwrap import dedent
 from typing import (
     Any,
@@ -18,10 +19,10 @@
 import numpy as np
 import pandas as pd
 
-from . import dtypes, duck_array_ops, formatting, ops
+from . import dtypes, duck_array_ops, formatting, formatting_html, ops
 from .arithmetic import SupportsArithmetic
 from .npcompat import DTypeLike
-from .options import _get_keep_attrs
+from .options import OPTIONS, _get_keep_attrs
 from .pycompat import dask_array_type
 from .rolling_exp import RollingExp
 from .utils import Frozen, ReprObject, either_dict_or_kwargs
@@ -134,6 +135,11 @@ def __array__(self: Any, dtype: DTypeLike = None) -> np.ndarray:
     def __repr__(self) -> str:
         return formatting.array_repr(self)
 
+    def _repr_html_(self):
+        if OPTIONS["display_style"] == "text":
+            return f"<pre>{escape(repr(self))}</pre>"
+        return formatting_html.array_repr(self)
+
     def _iter(self: Any) -> Iterator[Any]:
         for n in range(len(self)):
             yield self[n]
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index 12d5cbdc9f3..eba580f84bd 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -3,6 +3,7 @@
 import sys
 import warnings
 from collections import defaultdict
+from html import escape
 from numbers import Number
 from pathlib import Path
 from typing import (
@@ -39,6 +40,7 @@
     dtypes,
     duck_array_ops,
     formatting,
+    formatting_html,
     groupby,
     ops,
     resample,
@@ -1619,6 +1621,11 @@ def to_zarr(
     def __repr__(self) -> str:
         return formatting.dataset_repr(self)
 
+    def _repr_html_(self):
+        if OPTIONS["display_style"] == "text":
+            return f"<pre>{escape(repr(self))}</pre>"
+        return formatting_html.dataset_repr(self)
+
     def info(self, buf=None) -> None:
         """
         Concise summary of a Dataset variables and attributes.
diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py
new file mode 100644
index 00000000000..b03ecc12962
--- /dev/null
+++ b/xarray/core/formatting_html.py
@@ -0,0 +1,274 @@
+import uuid
+import pkg_resources
+from collections import OrderedDict
+from functools import partial
+from html import escape
+
+from .formatting import inline_variable_array_repr, short_data_repr
+
+
+CSS_FILE_PATH = "/".join(("static", "css", "style.css"))
+CSS_STYLE = pkg_resources.resource_string("xarray", CSS_FILE_PATH).decode("utf8")
+
+
+ICONS_SVG_PATH = "/".join(("static", "html", "icons-svg-inline.html"))
+ICONS_SVG = pkg_resources.resource_string("xarray", ICONS_SVG_PATH).decode("utf8")
+
+
+def short_data_repr_html(array):
+    """Format "data" for DataArray and Variable."""
+    internal_data = getattr(array, "variable", array)._data
+    if hasattr(internal_data, "_repr_html_"):
+        return internal_data._repr_html_()
+    return escape(short_data_repr(array))
+
+
+def format_dims(dims, coord_names):
+    if not dims:
+        return ""
+
+    dim_css_map = {
+        k: " class='xr-has-index'" if k in coord_names else "" for k, v in dims.items()
+    }
+
+    dims_li = "".join(
+        f"<li><span{dim_css_map[dim]}>" f"{escape(dim)}</span>: {size}</li>"
+        for dim, size in dims.items()
+    )
+
+    return f"<ul class='xr-dim-list'>{dims_li}</ul>"
+
+
+def summarize_attrs(attrs):
+    attrs_dl = "".join(
+        f"<dt><span>{escape(k)} :</span></dt>" f"<dd>{escape(str(v))}</dd>"
+        for k, v in attrs.items()
+    )
+
+    return f"<dl class='xr-attrs'>{attrs_dl}</dl>"
+
+
+def _icon(icon_name):
+    # icon_name should be defined in xarray/static/html/icon-svg-inline.html
+    return (
+        "<svg class='icon xr-{0}'>"
+        "<use xlink:href='#{0}'>"
+        "</use>"
+        "</svg>".format(icon_name)
+    )
+
+
+def _summarize_coord_multiindex(name, coord):
+    preview = f"({', '.join(escape(l) for l in coord.level_names)})"
+    return summarize_variable(
+        name, coord, is_index=True, dtype="MultiIndex", preview=preview
+    )
+
+
+def summarize_coord(name, var):
+    is_index = name in var.dims
+    if is_index:
+        coord = var.variable.to_index_variable()
+        if coord.level_names is not None:
+            coords = {}
+            coords[name] = _summarize_coord_multiindex(name, coord)
+            for lname in coord.level_names:
+                var = coord.get_level_variable(lname)
+                coords[lname] = summarize_variable(lname, var)
+            return coords
+
+    return {name: summarize_variable(name, var, is_index)}
+
+
+def summarize_coords(variables):
+    coords = {}
+    for k, v in variables.items():
+        coords.update(**summarize_coord(k, v))
+
+    vars_li = "".join(f"<li class='xr-var-item'>{v}</li>" for v in coords.values())
+
+    return f"<ul class='xr-var-list'>{vars_li}</ul>"
+
+
+def summarize_variable(name, var, is_index=False, dtype=None, preview=None):
+    variable = var.variable if hasattr(var, "variable") else var
+
+    cssclass_idx = " class='xr-has-index'" if is_index else ""
+    dims_str = f"({', '.join(escape(dim) for dim in var.dims)})"
+    name = escape(name)
+    dtype = dtype or var.dtype
+
+    # "unique" ids required to expand/collapse subsections
+    attrs_id = "attrs-" + str(uuid.uuid4())
+    data_id = "data-" + str(uuid.uuid4())
+    disabled = "" if len(var.attrs) else "disabled"
+
+    preview = preview or escape(inline_variable_array_repr(variable, 35))
+    attrs_ul = summarize_attrs(var.attrs)
+    data_repr = short_data_repr_html(variable)
+
+    attrs_icon = _icon("icon-file-text2")
+    data_icon = _icon("icon-database")
+
+    return (
+        f"<div class='xr-var-name'><span{cssclass_idx}>{name}</span></div>"
+        f"<div class='xr-var-dims'>{dims_str}</div>"
+        f"<div class='xr-var-dtype'>{dtype}</div>"
+        f"<div class='xr-var-preview xr-preview'>{preview}</div>"
+        f"<input id='{attrs_id}' class='xr-var-attrs-in' "
+        f"type='checkbox' {disabled}>"
+        f"<label for='{attrs_id}' title='Show/Hide attributes'>"
+        f"{attrs_icon}</label>"
+        f"<input id='{data_id}' class='xr-var-data-in' type='checkbox'>"
+        f"<label for='{data_id}' title='Show/Hide data repr'>"
+        f"{data_icon}</label>"
+        f"<div class='xr-var-attrs'>{attrs_ul}</div>"
+        f"<pre class='xr-var-data'>{data_repr}</pre>"
+    )
+
+
+def summarize_vars(variables):
+    vars_li = "".join(
+        f"<li class='xr-var-item'>{summarize_variable(k, v)}</li>"
+        for k, v in variables.items()
+    )
+
+    return f"<ul class='xr-var-list'>{vars_li}</ul>"
+
+
+def collapsible_section(
+    name, inline_details="", details="", n_items=None, enabled=True, collapsed=False
+):
+    # "unique" id to expand/collapse the section
+    data_id = "section-" + str(uuid.uuid4())
+
+    has_items = n_items is not None and n_items
+    n_items_span = "" if n_items is None else f" <span>({n_items})</span>"
+    enabled = "" if enabled and has_items else "disabled"
+    collapsed = "" if collapsed or not has_items else "checked"
+    tip = " title='Expand/collapse section'" if enabled else ""
+
+    return (
+        f"<input id='{data_id}' class='xr-section-summary-in' "
+        f"type='checkbox' {enabled} {collapsed}>"
+        f"<label for='{data_id}' class='xr-section-summary' {tip}>"
+        f"{name}:{n_items_span}</label>"
+        f"<div class='xr-section-inline-details'>{inline_details}</div>"
+        f"<div class='xr-section-details'>{details}</div>"
+    )
+
+
+def _mapping_section(mapping, name, details_func, max_items_collapse, enabled=True):
+    n_items = len(mapping)
+    collapsed = n_items >= max_items_collapse
+
+    return collapsible_section(
+        name,
+        details=details_func(mapping),
+        n_items=n_items,
+        enabled=enabled,
+        collapsed=collapsed,
+    )
+
+
+def dim_section(obj):
+    dim_list = format_dims(obj.dims, list(obj.coords))
+
+    return collapsible_section(
+        "Dimensions", inline_details=dim_list, enabled=False, collapsed=True
+    )
+
+
+def array_section(obj):
+    # "unique" id to expand/collapse the section
+    data_id = "section-" + str(uuid.uuid4())
+    collapsed = ""
+    preview = escape(inline_variable_array_repr(obj.variable, max_width=70))
+    data_repr = short_data_repr_html(obj)
+    data_icon = _icon("icon-database")
+
+    return (
+        "<div class='xr-array-wrap'>"
+        f"<input id='{data_id}' class='xr-array-in' type='checkbox' {collapsed}>"
+        f"<label for='{data_id}' title='Show/hide data repr'>{data_icon}</label>"
+        f"<div class='xr-array-preview xr-preview'><span>{preview}</span></div>"
+        f"<pre class='xr-array-data'>{data_repr}</pre>"
+        "</div>"
+    )
+
+
+coord_section = partial(
+    _mapping_section,
+    name="Coordinates",
+    details_func=summarize_coords,
+    max_items_collapse=25,
+)
+
+
+datavar_section = partial(
+    _mapping_section,
+    name="Data variables",
+    details_func=summarize_vars,
+    max_items_collapse=15,
+)
+
+
+attr_section = partial(
+    _mapping_section,
+    name="Attributes",
+    details_func=summarize_attrs,
+    max_items_collapse=10,
+)
+
+
+def _obj_repr(header_components, sections):
+    header = f"<div class='xr-header'>{''.join(h for h in header_components)}</div>"
+    sections = "".join(f"<li class='xr-section-item'>{s}</li>" for s in sections)
+
+    return (
+        "<div>"
+        f"{ICONS_SVG}<style>{CSS_STYLE}</style>"
+        "<div class='xr-wrap'>"
+        f"{header}"
+        f"<ul class='xr-sections'>{sections}</ul>"
+        "</div>"
+        "</div>"
+    )
+
+
+def array_repr(arr):
+    dims = OrderedDict((k, v) for k, v in zip(arr.dims, arr.shape))
+
+    obj_type = "xarray.{}".format(type(arr).__name__)
+    arr_name = "'{}'".format(arr.name) if getattr(arr, "name", None) else ""
+    coord_names = list(arr.coords) if hasattr(arr, "coords") else []
+
+    header_components = [
+        "<div class='xr-obj-type'>{}</div>".format(obj_type),
+        "<div class='xr-array-name'>{}</div>".format(arr_name),
+        format_dims(dims, coord_names),
+    ]
+
+    sections = [array_section(arr)]
+
+    if hasattr(arr, "coords"):
+        sections.append(coord_section(arr.coords))
+
+    sections.append(attr_section(arr.attrs))
+
+    return _obj_repr(header_components, sections)
+
+
+def dataset_repr(ds):
+    obj_type = "xarray.{}".format(type(ds).__name__)
+
+    header_components = [f"<div class='xr-obj-type'>{escape(obj_type)}</div>"]
+
+    sections = [
+        dim_section(ds),
+        coord_section(ds.coords),
+        datavar_section(ds.data_vars),
+        attr_section(ds.attrs),
+    ]
+
+    return _obj_repr(header_components, sections)
diff --git a/xarray/core/options.py b/xarray/core/options.py
index 2f464a33fb1..72f9ad8e1fa 100644
--- a/xarray/core/options.py
+++ b/xarray/core/options.py
@@ -8,6 +8,7 @@
 CMAP_SEQUENTIAL = "cmap_sequential"
 CMAP_DIVERGENT = "cmap_divergent"
 KEEP_ATTRS = "keep_attrs"
+DISPLAY_STYLE = "display_style"
 
 
 OPTIONS = {
@@ -19,9 +20,11 @@
     CMAP_SEQUENTIAL: "viridis",
     CMAP_DIVERGENT: "RdBu_r",
     KEEP_ATTRS: "default",
+    DISPLAY_STYLE: "text",
 }
 
 _JOIN_OPTIONS = frozenset(["inner", "outer", "left", "right", "exact"])
+_DISPLAY_OPTIONS = frozenset(["text", "html"])
 
 
 def _positive_integer(value):
@@ -35,6 +38,7 @@ def _positive_integer(value):
     FILE_CACHE_MAXSIZE: _positive_integer,
     WARN_FOR_UNCLOSED_FILES: lambda value: isinstance(value, bool),
     KEEP_ATTRS: lambda choice: choice in [True, False, "default"],
+    DISPLAY_STYLE: _DISPLAY_OPTIONS.__contains__,
 }
 
 
@@ -98,6 +102,9 @@ class set_options:
       attrs, ``False`` to always discard them, or ``'default'`` to use original
       logic that attrs should only be kept in unambiguous circumstances.
       Default: ``'default'``.
+    - ``display_style``: display style to use in jupyter for xarray objects.
+      Default: ``'text'``. Other options are ``'html'``.
+
 
     You can use ``set_options`` either as a context manager:
 
diff --git a/xarray/static/css/style.css b/xarray/static/css/style.css
new file mode 100644
index 00000000000..536b8ab6103
--- /dev/null
+++ b/xarray/static/css/style.css
@@ -0,0 +1,310 @@
+/* CSS stylesheet for displaying xarray objects in jupyterlab.
+ *
+ */
+
+.xr-wrap {
+  min-width: 300px;
+  max-width: 700px;
+}
+
+.xr-header {
+  padding-top: 6px;
+  padding-bottom: 6px;
+  margin-bottom: 4px;
+  border-bottom: solid 1px #ddd;
+}
+
+.xr-header > div,
+.xr-header > ul {
+  display: inline;
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+.xr-obj-type,
+.xr-array-name {
+  margin-left: 2px;
+  margin-right: 10px;
+}
+
+.xr-obj-type {
+  color: #555;
+}
+
+.xr-array-name {
+  color: #000;
+}
+
+.xr-sections {
+  padding-left: 0 !important;
+  display: grid;
+  grid-template-columns: 150px auto auto 1fr 20px 20px;
+}
+
+.xr-section-item {
+  display: contents;
+}
+
+.xr-section-item input {
+  display: none;
+}
+
+.xr-section-item input + label {
+  color: #ccc;
+}
+
+.xr-section-item input:enabled + label {
+  cursor: pointer;
+  color: #555;
+}
+
+.xr-section-item input:enabled + label:hover {
+  color: #000;
+}
+
+.xr-section-summary {
+  grid-column: 1;
+  color: #555;
+  font-weight: 500;
+}
+
+.xr-section-summary > span {
+  display: inline-block;
+  padding-left: 0.5em;
+}
+
+.xr-section-summary-in:disabled + label {
+  color: #555;
+}
+
+.xr-section-summary-in + label:before {
+  display: inline-block;
+  content: '►';
+  font-size: 11px;
+  width: 15px;
+  text-align: center;
+}
+
+.xr-section-summary-in:disabled + label:before {
+  color: #ccc;
+}
+
+.xr-section-summary-in:checked + label:before {
+  content: '▼';
+}
+
+.xr-section-summary-in:checked + label > span {
+  display: none;
+}
+
+.xr-section-summary,
+.xr-section-inline-details {
+  padding-top: 4px;
+  padding-bottom: 4px;
+}
+
+.xr-section-inline-details {
+  grid-column: 2 / -1;
+}
+
+.xr-section-details {
+  display: none;
+  grid-column: 1 / -1;
+  margin-bottom: 5px;
+}
+
+.xr-section-summary-in:checked ~ .xr-section-details {
+  display: contents;
+}
+
+.xr-array-wrap {
+  grid-column: 1 / -1;
+  display: grid;
+  grid-template-columns: 20px auto;
+}
+
+.xr-array-wrap > label {
+  grid-column: 1;
+  vertical-align: top;
+}
+
+.xr-preview {
+  color: #888;
+}
+
+.xr-array-preview,
+.xr-array-data {
+  padding: 0 5px !important;
+  grid-column: 2;
+}
+
+.xr-array-data,
+.xr-array-in:checked ~ .xr-array-preview {
+  display: none;
+}
+
+.xr-array-in:checked ~ .xr-array-data,
+.xr-array-preview {
+  display: inline-block;
+}
+
+.xr-dim-list {
+  display: inline-block !important;
+  list-style: none;
+  padding: 0 !important;
+  margin: 0;
+}
+
+.xr-dim-list li {
+  display: inline-block;
+  padding: 0;
+  margin: 0;
+}
+
+.xr-dim-list:before {
+  content: '(';
+}
+
+.xr-dim-list:after {
+  content: ')';
+}
+
+.xr-dim-list li:not(:last-child):after {
+  content: ',';
+  padding-right: 5px;
+}
+
+.xr-has-index {
+  font-weight: bold;
+}
+
+.xr-var-list,
+.xr-var-item {
+  display: contents;
+}
+
+.xr-var-item > div,
+.xr-var-item label,
+.xr-var-item > .xr-var-name span {
+  background-color: #fcfcfc;
+  margin-bottom: 0;
+}
+
+.xr-var-item > .xr-var-name:hover span {
+  padding-right: 5px;
+}
+
+.xr-var-list > li:nth-child(odd) > div,
+.xr-var-list > li:nth-child(odd) > label,
+.xr-var-list > li:nth-child(odd) > .xr-var-name span {
+  background-color: #efefef;
+}
+
+.xr-var-name {
+  grid-column: 1;
+}
+
+.xr-var-dims {
+  grid-column: 2;
+}
+
+.xr-var-dtype {
+  grid-column: 3;
+  text-align: right;
+  color: #555;
+}
+
+.xr-var-preview {
+  grid-column: 4;
+}
+
+.xr-var-name,
+.xr-var-dims,
+.xr-var-dtype,
+.xr-preview,
+.xr-attrs dt {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  padding-right: 10px;
+}
+
+.xr-var-name:hover,
+.xr-var-dims:hover,
+.xr-var-dtype:hover,
+.xr-attrs dt:hover {
+  overflow: visible;
+  width: auto;
+  z-index: 1;
+}
+
+.xr-var-attrs,
+.xr-var-data {
+  display: none;
+  background-color: #fff !important;
+  padding-bottom: 5px !important;
+}
+
+.xr-var-attrs-in:checked ~ .xr-var-attrs,
+.xr-var-data-in:checked ~ .xr-var-data {
+  display: block;
+}
+
+.xr-var-data > table {
+  float: right;
+}
+
+.xr-var-name span,
+.xr-var-data,
+.xr-attrs {
+  padding-left: 25px !important;
+}
+
+.xr-attrs,
+.xr-var-attrs,
+.xr-var-data {
+  grid-column: 1 / -1;
+}
+
+dl.xr-attrs {
+  padding: 0;
+  margin: 0;
+  display: grid;
+  grid-template-columns: 125px auto;
+}
+
+.xr-attrs dt, dd {
+  padding: 0;
+  margin: 0;
+  float: left;
+  padding-right: 10px;
+  width: auto;
+}
+
+.xr-attrs dt {
+  font-weight: normal;
+  grid-column: 1;
+}
+
+.xr-attrs dt:hover span {
+  display: inline-block;
+  background: #fff;
+  padding-right: 10px;
+}
+
+.xr-attrs dd {
+  grid-column: 2;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.xr-icon-database,
+.xr-icon-file-text2 {
+  display: inline-block;
+  vertical-align: middle;
+  width: 1em;
+  height: 1.5em !important;
+  stroke-width: 0;
+  stroke: currentColor;
+  fill: currentColor;
+}
diff --git a/xarray/static/html/icons-svg-inline.html b/xarray/static/html/icons-svg-inline.html
new file mode 100644
index 00000000000..c44f89c4304
--- /dev/null
+++ b/xarray/static/html/icons-svg-inline.html
@@ -0,0 +1,17 @@
+<svg style="position: absolute; width: 0; height: 0; overflow: hidden">
+<defs>
+<symbol id="icon-database" viewBox="0 0 32 32">
+<title>Show/Hide data repr</title>
+<path d="M16 0c-8.837 0-16 2.239-16 5v4c0 2.761 7.163 5 16 5s16-2.239 16-5v-4c0-2.761-7.163-5-16-5z"></path>
+<path d="M16 17c-8.837 0-16-2.239-16-5v6c0 2.761 7.163 5 16 5s16-2.239 16-5v-6c0 2.761-7.163 5-16 5z"></path>
+<path d="M16 26c-8.837 0-16-2.239-16-5v6c0 2.761 7.163 5 16 5s16-2.239 16-5v-6c0 2.761-7.163 5-16 5z"></path>
+</symbol>
+<symbol id="icon-file-text2" viewBox="0 0 32 32">
+<title>Show/Hide attributes</title>
+<path d="M28.681 7.159c-0.694-0.947-1.662-2.053-2.724-3.116s-2.169-2.030-3.116-2.724c-1.612-1.182-2.393-1.319-2.841-1.319h-15.5c-1.378 0-2.5 1.121-2.5 2.5v27c0 1.378 1.122 2.5 2.5 2.5h23c1.378 0 2.5-1.122 2.5-2.5v-19.5c0-0.448-0.137-1.23-1.319-2.841zM24.543 5.457c0.959 0.959 1.712 1.825 2.268 2.543h-4.811v-4.811c0.718 0.556 1.584 1.309 2.543 2.268zM28 29.5c0 0.271-0.229 0.5-0.5 0.5h-23c-0.271 0-0.5-0.229-0.5-0.5v-27c0-0.271 0.229-0.5 0.5-0.5 0 0 15.499-0 15.5 0v7c0 0.552 0.448 1 1 1h7v19.5z"></path>
+<path d="M23 26h-14c-0.552 0-1-0.448-1-1s0.448-1 1-1h14c0.552 0 1 0.448 1 1s-0.448 1-1 1z"></path>
+<path d="M23 22h-14c-0.552 0-1-0.448-1-1s0.448-1 1-1h14c0.552 0 1 0.448 1 1s-0.448 1-1 1z"></path>
+<path d="M23 18h-14c-0.552 0-1-0.448-1-1s0.448-1 1-1h14c0.552 0 1 0.448 1 1s-0.448 1-1 1z"></path>
+</symbol>
+</defs>
+</svg>


Test patch:
diff --git a/xarray/tests/test_formatting_html.py b/xarray/tests/test_formatting_html.py
new file mode 100644
index 00000000000..e7f54b22d06
--- /dev/null
+++ b/xarray/tests/test_formatting_html.py
@@ -0,0 +1,132 @@
+from distutils.version import LooseVersion
+
+import numpy as np
+import pandas as pd
+import pytest
+
+import xarray as xr
+from xarray.core import formatting_html as fh
+
+
+@pytest.fixture
+def dataarray():
+    return xr.DataArray(np.random.RandomState(0).randn(4, 6))
+
+
+@pytest.fixture
+def dask_dataarray(dataarray):
+    pytest.importorskip("dask")
+    return dataarray.chunk()
+
+
+@pytest.fixture
+def multiindex():
+    mindex = pd.MultiIndex.from_product(
+        [["a", "b"], [1, 2]], names=("level_1", "level_2")
+    )
+    return xr.Dataset({}, {"x": mindex})
+
+
+@pytest.fixture
+def dataset():
+    times = pd.date_range("2000-01-01", "2001-12-31", name="time")
+    annual_cycle = np.sin(2 * np.pi * (times.dayofyear.values / 365.25 - 0.28))
+
+    base = 10 + 15 * annual_cycle.reshape(-1, 1)
+    tmin_values = base + 3 * np.random.randn(annual_cycle.size, 3)
+    tmax_values = base + 10 + 3 * np.random.randn(annual_cycle.size, 3)
+
+    return xr.Dataset(
+        {
+            "tmin": (("time", "location"), tmin_values),
+            "tmax": (("time", "location"), tmax_values),
+        },
+        {"time": times, "location": ["<IA>", "IN", "IL"]},
+        attrs={"description": "Test data."},
+    )
+
+
+def test_short_data_repr_html(dataarray):
+    data_repr = fh.short_data_repr_html(dataarray)
+    assert data_repr.startswith("array")
+
+
+def test_short_data_repr_html_dask(dask_dataarray):
+    import dask
+
+    if LooseVersion(dask.__version__) < "2.0.0":
+        assert not hasattr(dask_dataarray.data, "_repr_html_")
+        data_repr = fh.short_data_repr_html(dask_dataarray)
+        assert (
+            data_repr
+            == "dask.array&lt;xarray-&lt;this-array&gt;, shape=(4, 6), dtype=float64, chunksize=(4, 6)&gt;"
+        )
+    else:
+        assert hasattr(dask_dataarray.data, "_repr_html_")
+        data_repr = fh.short_data_repr_html(dask_dataarray)
+        assert data_repr == dask_dataarray.data._repr_html_()
+
+
+def test_format_dims_no_dims():
+    dims, coord_names = {}, []
+    formatted = fh.format_dims(dims, coord_names)
+    assert formatted == ""
+
+
+def test_format_dims_unsafe_dim_name():
+    dims, coord_names = {"<x>": 3, "y": 2}, []
+    formatted = fh.format_dims(dims, coord_names)
+    assert "&lt;x&gt;" in formatted
+
+
+def test_format_dims_non_index():
+    dims, coord_names = {"x": 3, "y": 2}, ["time"]
+    formatted = fh.format_dims(dims, coord_names)
+    assert "class='xr-has-index'" not in formatted
+
+
+def test_format_dims_index():
+    dims, coord_names = {"x": 3, "y": 2}, ["x"]
+    formatted = fh.format_dims(dims, coord_names)
+    assert "class='xr-has-index'" in formatted
+
+
+def test_summarize_attrs_with_unsafe_attr_name_and_value():
+    attrs = {"<x>": 3, "y": "<pd.DataFrame>"}
+    formatted = fh.summarize_attrs(attrs)
+    assert "<dt><span>&lt;x&gt; :</span></dt>" in formatted
+    assert "<dt><span>y :</span></dt>" in formatted
+    assert "<dd>3</dd>" in formatted
+    assert "<dd>&lt;pd.DataFrame&gt;</dd>" in formatted
+
+
+def test_repr_of_dataarray(dataarray):
+    formatted = fh.array_repr(dataarray)
+    assert "dim_0" in formatted
+    # has an expandable data section
+    assert formatted.count("class='xr-array-in' type='checkbox' >") == 1
+    # coords and attrs don't have an items so they'll be be disabled and collapsed
+    assert (
+        formatted.count("class='xr-section-summary-in' type='checkbox' disabled >") == 2
+    )
+
+
+def test_summary_of_multiindex_coord(multiindex):
+    idx = multiindex.x.variable.to_index_variable()
+    formatted = fh._summarize_coord_multiindex("foo", idx)
+    assert "(level_1, level_2)" in formatted
+    assert "MultiIndex" in formatted
+    assert "<span class='xr-has-index'>foo</span>" in formatted
+
+
+def test_repr_of_multiindex(multiindex):
+    formatted = fh.dataset_repr(multiindex)
+    assert "(x)" in formatted
+
+
+def test_repr_of_dataset(dataset):
+    formatted = fh.dataset_repr(dataset)
+    # coords, attrs, and data_vars are expanded
+    assert (
+        formatted.count("class='xr-section-summary-in' type='checkbox'  checked>") == 3
+    )
diff --git a/xarray/tests/test_options.py b/xarray/tests/test_options.py
index 2aa77ecd6b3..f155acbf494 100644
--- a/xarray/tests/test_options.py
+++ b/xarray/tests/test_options.py
@@ -67,6 +67,16 @@ def test_nested_options():
     assert OPTIONS["display_width"] == original
 
 
+def test_display_style():
+    original = "text"
+    assert OPTIONS["display_style"] == original
+    with pytest.raises(ValueError):
+        xarray.set_options(display_style="invalid_str")
+    with xarray.set_options(display_style="html"):
+        assert OPTIONS["display_style"] == "html"
+    assert OPTIONS["display_style"] == original
+
+
 def create_test_dataset_attrs(seed=0):
     ds = create_test_data(seed)
     ds.attrs = {"attr1": 5, "attr2": "history", "attr3": {"nested": "more_info"}}
@@ -164,3 +174,30 @@ def test_merge_attr_retention(self):
         # option doesn't affect this
         result = merge([da1, da2])
         assert result.attrs == original_attrs
+
+    def test_display_style_text(self):
+        ds = create_test_dataset_attrs()
+        text = ds._repr_html_()
+        assert text.startswith("<pre>")
+        assert "&#x27;nested&#x27;" in text
+        assert "&lt;xarray.Dataset&gt;" in text
+
+    def test_display_style_html(self):
+        ds = create_test_dataset_attrs()
+        with xarray.set_options(display_style="html"):
+            html = ds._repr_html_()
+            assert html.startswith("<div>")
+            assert "&#x27;nested&#x27;" in html
+
+    def test_display_dataarray_style_text(self):
+        da = create_test_dataarray_attrs()
+        text = da._repr_html_()
+        assert text.startswith("<pre>")
+        assert "&lt;xarray.DataArray &#x27;var1&#x27;" in text
+
+    def test_display_dataarray_style_html(self):
+        da = create_test_dataarray_attrs()
+        with xarray.set_options(display_style="html"):
+            html = da._repr_html_()
+            assert html.startswith("<div>")
+            assert "#x27;nested&#x27;" in html