Repository: streamlit/streamlit
Pull request: Allow hiding dataframe columns from UI
## Describe your changes

Allow users to show & hide dataframe columns from UI via the column menu:

<img width="307" alt="image" src="https://github.com/user-attachments/assets/20acf147-9742-4df4-8905-b2dd7c16b55d" />

This also adds a new toolbar action that allows showing & hiding all columns of the dataframe:

<img width="305" alt="image" src="https://github.com/user-attachments/assets/5a0f40da-7ce3-4faf-a893-3b3f06cbdfae" />

- [Demo](https://interactive-dataframe.streamlit.app/)

## GitHub Issue Link (if applicable)

- Closes https://github.com/streamlit/streamlit/issues/6870

## Testing Plan

- Added unit & e2e tests.

---

**Contribution License Agreement**

By submitting this pull request you agree that all contributions to this project are made under the Apache 2.0 license.


Problem statement:
Show and hide dataframe columns in UI
I think it will be nice to have where we can show or hide dataframe columns.

For example if we have too many columns and we just want some for comparison we can hide them and later as necessary we can show too

---

Community voting on feature requests enables the Streamlit team to understand which features are most important to our users.

**If you'd like the Streamlit team to prioritize this feature request, please use the 👍 (thumbs up emoji) reaction in response to the initial post.**

Hints from issue discussion:
I assume you mean by clicking on them in the UI, not programmatically? programmatically you can do this now via the new `column_order` parameter. 
Talking about via UI🙂
+1

Commit message: Update menu state

Patch to review:
diff --git a/frontend/lib/src/components/widgets/DataFrame/ColumnMenu.tsx b/frontend/lib/src/components/widgets/DataFrame/ColumnMenu.tsx
index ed33cab8acc1..e03e2c88317c 100644
--- a/frontend/lib/src/components/widgets/DataFrame/ColumnMenu.tsx
+++ b/frontend/lib/src/components/widgets/DataFrame/ColumnMenu.tsx
@@ -44,6 +44,8 @@ export interface ColumnMenuProps {
   onPinColumn: () => void
   // Callback to unpin the column
   onUnpinColumn: () => void
+  // Callback to hide the column
+  onHideColumn: () => void
 }
 
 /**
@@ -57,6 +59,7 @@ function ColumnMenu({
   onUnpinColumn,
   onCloseMenu,
   onSortColumn,
+  onHideColumn,
 }: ColumnMenuProps): ReactElement {
   const theme: EmotionTheme = useTheme()
   const { colors, fontSizes, radii, fontWeights } = theme
@@ -159,6 +162,20 @@ function ColumnMenu({
               Pin column
             </StyledMenuListItem>
           )}
+          <StyledMenuListItem
+            onClick={() => {
+              onHideColumn()
+              closeMenu()
+            }}
+          >
+            <DynamicIcon
+              size={"base"}
+              margin="0"
+              color="inherit"
+              iconValue=":material/visibility_off:"
+            />
+            Hide column
+          </StyledMenuListItem>
         </StyledMenuList>
       }
       placement={PLACEMENT.bottomRight}
diff --git a/frontend/lib/src/components/widgets/DataFrame/ColumnVisibilityMenu.tsx b/frontend/lib/src/components/widgets/DataFrame/ColumnVisibilityMenu.tsx
new file mode 100644
index 000000000000..9f205fc60461
--- /dev/null
+++ b/frontend/lib/src/components/widgets/DataFrame/ColumnVisibilityMenu.tsx
@@ -0,0 +1,218 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { ReactElement } from "react"
+
+import { useTheme } from "@emotion/react"
+import { PLACEMENT, TRIGGER_TYPE, Popover as UIPopover } from "baseui/popover"
+import { Visibility } from "@emotion-icons/material-outlined"
+import {
+  LABEL_PLACEMENT,
+  STYLE_TYPE,
+  Checkbox as UICheckbox,
+} from "baseui/checkbox"
+import { transparentize } from "color2k"
+
+import { hasLightBackgroundColor } from "~lib/theme"
+
+import { BaseColumn } from "./columns"
+
+const NAMELESS_INDEX_NAME = "(index)"
+export interface ColumnVisibilityMenuProps {
+  columns: BaseColumn[]
+  hideColumn: (columnId: string) => void
+  showColumn: (columnId: string) => void
+  children: React.ReactNode
+  isOpen: boolean
+  onClose: () => void
+}
+
+const ColumnVisibilityMenu: React.FC<ColumnVisibilityMenuProps> = ({
+  columns,
+  hideColumn,
+  showColumn,
+  children,
+  isOpen,
+  onClose,
+}): ReactElement => {
+  const theme = useTheme()
+
+  return (
+    <div data-testid="stPopover" className="stPopover">
+      <UIPopover
+        triggerType={TRIGGER_TYPE.click}
+        placement={PLACEMENT.bottomRight}
+        autoFocus={true}
+        focusLock={true}
+        content={() => (
+          <div
+            style={{
+              paddingTop: theme.spacing.xs,
+              paddingBottom: theme.spacing.xs,
+            }}
+          >
+            {columns.map(column => (
+              <UICheckbox
+                key={column.id}
+                checked={column.isHidden !== true}
+                onChange={(e: React.ChangeEvent<HTMLInputElement>): void => {
+                  if (e.target.checked) {
+                    showColumn(column.id)
+                  } else {
+                    hideColumn(column.id)
+                  }
+                }}
+                aria-label={column.title}
+                checkmarkType={STYLE_TYPE.default}
+                labelPlacement={LABEL_PLACEMENT.right}
+                overrides={{
+                  Root: {
+                    style: ({
+                      $isFocusVisible,
+                    }: {
+                      $isFocusVisible: boolean
+                    }) => ({
+                      marginBottom: theme.spacing.none,
+                      marginTop: theme.spacing.none,
+                      paddingLeft: theme.spacing.sm,
+                      paddingRight: theme.spacing.sm,
+                      paddingTop: theme.spacing.twoXS,
+                      paddingBottom: theme.spacing.twoXS,
+                      backgroundColor: $isFocusVisible
+                        ? theme.colors.darkenedBgMix25
+                        : "",
+                      display: "flex",
+                      alignItems: "start",
+                    }),
+                  },
+                  Checkmark: {
+                    style: ({
+                      $isFocusVisible,
+                      $checked,
+                    }: {
+                      $isFocusVisible: boolean
+                      $checked: boolean
+                    }) => {
+                      const borderColor = $checked
+                        ? theme.colors.primary
+                        : theme.colors.fadedText40
+
+                      return {
+                        outline: 0,
+                        width: theme.sizes.checkbox,
+                        height: theme.sizes.checkbox,
+                        marginTop: theme.spacing.twoXS,
+                        marginLeft: 0,
+                        marginBottom: 0,
+                        boxShadow:
+                          $isFocusVisible && $checked
+                            ? `0 0 0 0.2rem ${transparentize(
+                                theme.colors.primary,
+                                0.5
+                              )}`
+                            : "",
+                        // This is painfully verbose, but baseweb seems to internally
+                        // use the long-hand version, which means we can't use the
+                        // shorthand names here as if we do we'll end up with warn
+                        // logs spamming us every time a checkbox is rendered.
+                        borderLeftWidth: theme.sizes.borderWidth,
+                        borderRightWidth: theme.sizes.borderWidth,
+                        borderTopWidth: theme.sizes.borderWidth,
+                        borderBottomWidth: theme.sizes.borderWidth,
+                        borderLeftColor: borderColor,
+                        borderRightColor: borderColor,
+                        borderTopColor: borderColor,
+                        borderBottomColor: borderColor,
+                      }
+                    },
+                  },
+                  Label: {
+                    style: {
+                      lineHeight: theme.lineHeights.small,
+                      paddingLeft: theme.spacing.sm,
+                      position: "relative",
+                      color: theme.colors.bodyText,
+                      fontSize: theme.fontSizes.sm,
+                      fontWeight: theme.fontWeights.normal,
+                    },
+                  },
+                }}
+              >
+                {!column.title && column.isIndex
+                  ? NAMELESS_INDEX_NAME
+                  : column.title}
+              </UICheckbox>
+            ))}
+          </div>
+        )}
+        isOpen={isOpen}
+        onClickOutside={onClose}
+        onClick={() => (isOpen ? onClose() : undefined)}
+        onEsc={onClose}
+        ignoreBoundary={false}
+        overrides={{
+          Body: {
+            props: {
+              "data-testid": "stDataFrameColumnVisibilityMenu",
+            },
+            style: {
+              // This is annoying, but a bunch of warnings get logged when the
+              // shorthand version `borderRadius` is used here since the long
+              // names are used by BaseWeb and mixing the two is apparently
+              // bad :(
+              borderTopLeftRadius: theme.radii.md,
+              borderTopRightRadius: theme.radii.md,
+              borderBottomLeftRadius: theme.radii.md,
+              borderBottomRightRadius: theme.radii.md,
+
+              paddingTop: "0 !important",
+              paddingBottom: "0 !important",
+              paddingLeft: "0 !important",
+              paddingRight: "0 !important",
+
+              backgroundColor: "transparent",
+              border: `${theme.sizes.borderWidth} solid ${theme.colors.borderColor}`,
+            },
+          },
+          Inner: {
+            style: {
+              backgroundColor: hasLightBackgroundColor(theme)
+                ? theme.colors.bgColor
+                : theme.colors.secondaryBg,
+              color: theme.colors.bodyText,
+              fontSize: theme.fontSizes.sm,
+              fontWeight: theme.fontWeights.normal,
+              minWidth: "7rem",
+              maxWidth: "15rem",
+              maxHeight: "15rem",
+              overflow: "auto",
+              // See the long comment about `borderRadius`. The same applies here
+              // to `padding`.
+              paddingTop: "0 !important",
+              paddingBottom: "0 !important",
+              paddingLeft: "0 !important",
+              paddingRight: "0 !important",
+            },
+          },
+        }}
+      >
+        {<div>{children}</div>}
+      </UIPopover>
+    </div>
+  )
+}
+
+export default ColumnVisibilityMenu
diff --git a/frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx b/frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx
index 8aaa6e505305..7462d45801c2 100644
--- a/frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx
+++ b/frontend/lib/src/components/widgets/DataFrame/DataFrame.tsx
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-import React, { ReactElement, useCallback } from "react"
+import React, { ReactElement, useCallback, useEffect } from "react"
 
 import {
   CompactSelection,
@@ -34,6 +34,7 @@ import {
   Delete,
   FileDownload,
   Search,
+  Visibility,
 } from "@emotion-icons/material-outlined"
 
 import { Arrow as ArrowProto } from "@streamlit/protobuf"
@@ -70,6 +71,7 @@ import {
 import { getTextCell, ImageCellEditor, toGlideColumn } from "./columns"
 import Tooltip from "./Tooltip"
 import { StyledResizableContainer } from "./styled-components"
+import ColumnVisibilityMenu from "./ColumnVisibilityMenu"
 
 import "@glideapps/glide-data-grid/dist/index.css"
 import "@glideapps/glide-data-grid-cells/dist/index.css"
@@ -156,6 +158,8 @@ function DataFrame({
     // The bounds of the column header:
     headerBounds: Rectangle
   }>()
+  const [showColumnVisibilityMenu, setShowColumnVisibilityMenu] =
+    React.useState(false)
 
   // Determine if the device is primary using touch as input:
   const isTouchDevice = React.useMemo<boolean>(
@@ -220,12 +224,11 @@ function DataFrame({
 
   const [columnOrder, setColumnOrder] = React.useState(element.columnOrder)
 
-  const { columns: originalColumns, setColumnConfigMapping } = useColumnLoader(
-    element,
-    data,
-    disabled,
-    columnOrder
-  )
+  const {
+    columns: originalColumns,
+    allColumns,
+    setColumnConfigMapping,
+  } = useColumnLoader(element, data, disabled, columnOrder)
 
   /**
    * On the first rendering, try to load initial widget state if
@@ -645,6 +648,45 @@ function DataFrame({
     }, 1)
   }, [resizableSize, numRows, glideColumns])
 
+  const hideColumn = React.useCallback(
+    (columnId: string) => {
+      setColumnConfigMapping(prevColumnConfigMapping => {
+        const newColumnConfigMapping = new Map(prevColumnConfigMapping)
+        const existingConfig = newColumnConfigMapping.get(columnId)
+        newColumnConfigMapping.set(columnId, {
+          ...(existingConfig || {}),
+          hidden: true,
+        })
+        return newColumnConfigMapping
+      })
+      clearSelection(true, false)
+    },
+    [clearSelection, setColumnConfigMapping]
+  )
+
+  const showColumn = React.useCallback(
+    (columnId: string) => {
+      setColumnConfigMapping(prevColumnConfigMapping => {
+        const newColumnConfigMapping = new Map(prevColumnConfigMapping)
+        const existingConfig = newColumnConfigMapping.get(columnId)
+        newColumnConfigMapping.set(columnId, {
+          ...(existingConfig || {}),
+          hidden: false,
+        })
+        return newColumnConfigMapping
+      })
+      clearSelection(true, false)
+    },
+    [clearSelection, setColumnConfigMapping]
+  )
+
+  // Hide the column visibility menu if all columns are visible:
+  useEffect(() => {
+    if (allColumns.length == columns.length) {
+      setShowColumnVisibilityMenu(false)
+    }
+  }, [allColumns.length, columns.length])
+
   return (
     <StyledResizableContainer
       className="stDataFrame"
@@ -702,12 +744,28 @@ function DataFrame({
         locked={
           (isRowSelected && !isRowSelectionActivated) ||
           isCellSelected ||
-          (isTouchDevice && isFocused)
+          (isTouchDevice && isFocused) ||
+          showColumnVisibilityMenu
         }
         onExpand={expand}
         onCollapse={collapse}
         target={StyledResizableContainer}
       >
+        {!isEmptyTable && allColumns.length > columns.length && (
+          <ColumnVisibilityMenu
+            columns={allColumns}
+            hideColumn={hideColumn}
+            showColumn={showColumn}
+            isOpen={showColumnVisibilityMenu}
+            onClose={() => setShowColumnVisibilityMenu(false)}
+          >
+            <ToolbarAction
+              label="Show/hide columns"
+              icon={Visibility}
+              onClick={() => setShowColumnVisibilityMenu(true)}
+            />
+          </ColumnVisibilityMenu>
+        )}
         {((isRowSelectionActivated && isRowSelected) ||
           (isColumnSelectionActivated && isColumnSelected)) && (
           // Add clear selection action if selections are active
@@ -1066,6 +1124,9 @@ function DataFrame({
           onPinColumn={() => {
             pinColumn(originalColumns[showMenu.columnIdx].id)
           }}
+          onHideColumn={() => {
+            hideColumn(originalColumns[showMenu.columnIdx].id)
+          }}
         ></ColumnMenu>
       )}
     </StyledResizableContainer>
diff --git a/frontend/lib/src/components/widgets/DataFrame/hooks/useColumnLoader.ts b/frontend/lib/src/components/widgets/DataFrame/hooks/useColumnLoader.ts
index 590055520005..8576a3971410 100644
--- a/frontend/lib/src/components/widgets/DataFrame/hooks/useColumnLoader.ts
+++ b/frontend/lib/src/components/widgets/DataFrame/hooks/useColumnLoader.ts
@@ -221,7 +221,11 @@ export function getColumnConfig(configJson: string): Map<string, any> {
 }
 
 type ColumnLoaderReturn = {
+  // All the visible columns:
   columns: BaseColumn[]
+  // All the columns of the dataframe, including hidden ones:
+  allColumns: BaseColumn[]
+  // Callback to set the column config state:
   setColumnConfigMapping: React.Dispatch<
     React.SetStateAction<Map<string, any>>
   >
@@ -296,58 +300,67 @@ function useColumnLoader(
     (notNullOrUndefined(element.width) && element.width > 0)
 
   // Converts the columns from Arrow into columns compatible with glide-data-grid
-  const columns: BaseColumn[] = React.useMemo(() => {
-    const visibleColumns = initAllColumnsFromArrow(data)
-      .map(column => {
-        // Apply column configurations
-        let updatedColumn = {
-          ...column,
-          ...applyColumnConfig(column, columnConfigMapping),
-          isStretched: stretchColumns,
-        } as BaseColumnProps
-        const ColumnType = getColumnType(updatedColumn)
-
-        // Make sure editing is deactivated if the column is read-only, disabled,
-        // or a not editable type.
-        if (
-          element.editingMode === ArrowProto.EditingMode.READ_ONLY ||
-          disabled ||
-          ColumnType.isEditableType === false
-        ) {
-          updatedColumn = {
-            ...updatedColumn,
-            isEditable: false,
-          }
+  const allColumns: BaseColumn[] = React.useMemo(() => {
+    return initAllColumnsFromArrow(data).map(column => {
+      // Apply column configurations
+      let updatedColumn = {
+        ...column,
+        ...applyColumnConfig(column, columnConfigMapping),
+        isStretched: stretchColumns,
+      } as BaseColumnProps
+      const ColumnType = getColumnType(updatedColumn)
+
+      // Make sure editing is deactivated if the column is read-only, disabled,
+      // or a not editable type.
+      if (
+        element.editingMode === ArrowProto.EditingMode.READ_ONLY ||
+        disabled ||
+        ColumnType.isEditableType === false
+      ) {
+        updatedColumn = {
+          ...updatedColumn,
+          isEditable: false,
+        }
+      }
+
+      if (
+        element.editingMode !== ArrowProto.EditingMode.READ_ONLY &&
+        updatedColumn.isEditable == true
+      ) {
+        // Set editable icon for all editable columns:
+        updatedColumn = {
+          ...updatedColumn,
+          icon: "editable",
         }
 
+        // Make sure that required columns are not hidden when editing mode is dynamic:
         if (
-          element.editingMode !== ArrowProto.EditingMode.READ_ONLY &&
-          updatedColumn.isEditable == true
+          updatedColumn.isRequired &&
+          element.editingMode === ArrowProto.EditingMode.DYNAMIC
         ) {
-          // Set editable icon for all editable columns:
           updatedColumn = {
             ...updatedColumn,
-            icon: "editable",
-          }
-
-          // Make sure that required columns are not hidden when editing mode is dynamic:
-          if (
-            updatedColumn.isRequired &&
-            element.editingMode === ArrowProto.EditingMode.DYNAMIC
-          ) {
-            updatedColumn = {
-              ...updatedColumn,
-              isHidden: false,
-            }
+            isHidden: false,
           }
         }
+      }
 
-        return ColumnType(updatedColumn, theme)
-      })
-      .filter(column => {
-        // Filter out all columns that are hidden
-        return !column.isHidden
-      })
+      return ColumnType(updatedColumn, theme)
+    })
+  }, [
+    data,
+    columnConfigMapping,
+    stretchColumns,
+    element.editingMode,
+    disabled,
+    theme,
+  ])
+
+  const columns: BaseColumn[] = React.useMemo(() => {
+    const visibleColumns = allColumns.filter(column => {
+      // Filter out all columns that are hidden
+      return !column.isHidden
+    })
 
     const pinnedColumns: BaseColumn[] = []
     const unpinnedColumns: BaseColumn[] = []
@@ -402,18 +415,11 @@ function useColumnLoader(
     return orderedColumns.length > 0
       ? orderedColumns
       : [ObjectColumn(initEmptyIndexColumn())]
-  }, [
-    data,
-    columnConfigMapping,
-    stretchColumns,
-    disabled,
-    element.editingMode,
-    columnOrder,
-    theme,
-  ])
+  }, [columnOrder, allColumns])
 
   return {
     columns,
+    allColumns,
     setColumnConfigMapping,
   }
 }


Code review comments:
diff_hunk: @@ -0,0 +1,218 @@
+/**
+ * Copyright (c) Streamlit Inc. (2018-2022) Snowflake Inc. (2022-2025)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { ReactElement } from "react"
+
+import { useTheme } from "@emotion/react"
+import { PLACEMENT, TRIGGER_TYPE, Popover as UIPopover } from "baseui/popover"
+import { Visibility } from "@emotion-icons/material-outlined"
path: frontend/lib/src/components/widgets/DataFrame/ColumnVisibilityMenu.tsx
text: @user1:
## Unused variable, import, function or class

Unused import Visibility.

[Show more details](https://github.com/streamlit/streamlit/security/code-scanning/9970)